happy-coder 0.7.2 → 0.9.0-0

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.mjs CHANGED
@@ -18,19 +18,19 @@ import 'node:events';
18
18
  import 'socket.io-client';
19
19
  import tweetnacl from 'tweetnacl';
20
20
  import 'expo-server-sdk';
21
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
- import { createServer } from 'node:http';
23
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
24
- import * as z from 'zod';
25
- import { z as z$1 } from 'zod';
26
21
  import { spawn as spawn$1, exec, execSync as execSync$1 } from 'child_process';
27
22
  import { promisify } from 'util';
28
23
  import { createHash } from 'crypto';
24
+ import * as z from 'zod';
25
+ import { z as z$1 } from 'zod';
29
26
  import fastify from 'fastify';
30
27
  import { validatorCompiler, serializerCompiler } from 'fastify-type-provider-zod';
31
28
  import os from 'os';
32
29
  import qrcode from 'qrcode-terminal';
33
30
  import open$1 from 'open';
31
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
32
+ import { createServer } from 'node:http';
33
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
34
34
  import { existsSync as existsSync$1, writeFileSync, chmodSync, unlinkSync } from 'fs';
35
35
 
36
36
  class Session {
@@ -42,6 +42,7 @@ class Session {
42
42
  claudeEnvVars;
43
43
  claudeArgs;
44
44
  mcpServers;
45
+ allowedTools;
45
46
  _onModeChange;
46
47
  sessionId;
47
48
  mode = "local";
@@ -56,6 +57,7 @@ class Session {
56
57
  this.claudeEnvVars = opts.claudeEnvVars;
57
58
  this.claudeArgs = opts.claudeArgs;
58
59
  this.mcpServers = opts.mcpServers;
60
+ this.allowedTools = opts.allowedTools;
59
61
  this._onModeChange = opts.onModeChange;
60
62
  this.client.keepAlive(this.thinking, this.mode);
61
63
  setInterval(() => {
@@ -114,6 +116,29 @@ function projectPath() {
114
116
  return path;
115
117
  }
116
118
 
119
+ function trimIdent(text) {
120
+ const lines = text.split("\n");
121
+ while (lines.length > 0 && lines[0].trim() === "") {
122
+ lines.shift();
123
+ }
124
+ while (lines.length > 0 && lines[lines.length - 1].trim() === "") {
125
+ lines.pop();
126
+ }
127
+ const minSpaces = lines.reduce((min, line) => {
128
+ if (line.trim() === "") {
129
+ return min;
130
+ }
131
+ const leadingSpaces = line.match(/^\s*/)[0].length;
132
+ return Math.min(min, leadingSpaces);
133
+ }, Infinity);
134
+ const trimmedLines = lines.map((line) => line.slice(minSpaces));
135
+ return trimmedLines.join("\n");
136
+ }
137
+
138
+ const systemPrompt = trimIdent(`
139
+ ALWAYS when you start a new chat - you must call a tool "mcp__happy__change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a change to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human.
140
+ `);
141
+
117
142
  dirname$1(fileURLToPath$1(import.meta.url));
118
143
  async function claudeLocal(opts) {
119
144
  const projectDir = getProjectPath(opts.path);
@@ -161,6 +186,13 @@ async function claudeLocal(opts) {
161
186
  if (startFrom) {
162
187
  args.push("--resume", startFrom);
163
188
  }
189
+ args.push("--append-system-prompt", systemPrompt);
190
+ if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) {
191
+ args.push("--mcp-config", JSON.stringify({ mcpServers: opts.mcpServers }));
192
+ }
193
+ if (opts.allowedTools && opts.allowedTools.length > 0) {
194
+ args.push("--allowedTools", opts.allowedTools.join(","));
195
+ }
164
196
  if (opts.claudeArgs) {
165
197
  args.push(...opts.claudeArgs);
166
198
  }
@@ -505,7 +537,9 @@ async function claudeLocalLauncher(session) {
505
537
  sessionId: session.sessionId,
506
538
  workingDirectory: session.path,
507
539
  onMessage: (message) => {
508
- session.client.sendClaudeSessionMessage(message);
540
+ if (message.type !== "summary") {
541
+ session.client.sendClaudeSessionMessage(message);
542
+ }
509
543
  }
510
544
  });
511
545
  let exitReason = null;
@@ -535,7 +569,9 @@ async function claudeLocalLauncher(session) {
535
569
  }
536
570
  session.client.setHandler("abort", doAbort);
537
571
  session.client.setHandler("switch", doSwitch);
538
- session.queue.setOnMessage(doSwitch);
572
+ session.queue.setOnMessage((message, mode) => {
573
+ doSwitch();
574
+ });
539
575
  if (session.queue.size() > 0) {
540
576
  return "switch";
541
577
  }
@@ -556,7 +592,9 @@ async function claudeLocalLauncher(session) {
556
592
  onThinkingChange: session.onThinkingChange,
557
593
  abort: processAbortController.signal,
558
594
  claudeEnvVars: session.claudeEnvVars,
559
- claudeArgs: session.claudeArgs
595
+ claudeArgs: session.claudeArgs,
596
+ mcpServers: session.mcpServers,
597
+ allowedTools: session.allowedTools
560
598
  });
561
599
  if (!exitReason) {
562
600
  exitReason = "exit";
@@ -864,16 +902,19 @@ async function streamToStdin(stream, stdin, abort) {
864
902
  }
865
903
 
866
904
  class Query {
867
- constructor(childStdin, childStdout, processExitPromise) {
905
+ constructor(childStdin, childStdout, processExitPromise, canCallTool) {
868
906
  this.childStdin = childStdin;
869
907
  this.childStdout = childStdout;
870
908
  this.processExitPromise = processExitPromise;
909
+ this.canCallTool = canCallTool;
871
910
  this.readMessages();
872
911
  this.sdkMessages = this.readSdkMessages();
873
912
  }
874
913
  pendingControlResponses = /* @__PURE__ */ new Map();
914
+ cancelControllers = /* @__PURE__ */ new Map();
875
915
  sdkMessages;
876
916
  inputStream = new Stream();
917
+ canCallTool;
877
918
  /**
878
919
  * Set an error on the stream
879
920
  */
@@ -918,6 +959,12 @@ class Query {
918
959
  handler(controlResponse.response);
919
960
  }
920
961
  continue;
962
+ } else if (message.type === "control_request") {
963
+ await this.handleControlRequest(message);
964
+ continue;
965
+ } else if (message.type === "control_cancel_request") {
966
+ this.handleControlCancelRequest(message);
967
+ continue;
921
968
  }
922
969
  this.inputStream.enqueue(message);
923
970
  } catch (e) {
@@ -930,6 +977,7 @@ class Query {
930
977
  this.inputStream.error(error);
931
978
  } finally {
932
979
  this.inputStream.done();
980
+ this.cleanupControllers();
933
981
  rl.close();
934
982
  }
935
983
  }
@@ -973,6 +1021,77 @@ class Query {
973
1021
  childStdin.write(JSON.stringify(sdkRequest) + "\n");
974
1022
  });
975
1023
  }
1024
+ /**
1025
+ * Handle incoming control requests for tool permissions
1026
+ * Replicates the exact logic from the SDK's handleControlRequest method
1027
+ */
1028
+ async handleControlRequest(request) {
1029
+ if (!this.childStdin) {
1030
+ logDebug("Cannot handle control request - no stdin available");
1031
+ return;
1032
+ }
1033
+ const controller = new AbortController();
1034
+ this.cancelControllers.set(request.request_id, controller);
1035
+ try {
1036
+ const response = await this.processControlRequest(request, controller.signal);
1037
+ const controlResponse = {
1038
+ type: "control_response",
1039
+ response: {
1040
+ subtype: "success",
1041
+ request_id: request.request_id,
1042
+ response
1043
+ }
1044
+ };
1045
+ this.childStdin.write(JSON.stringify(controlResponse) + "\n");
1046
+ } catch (error) {
1047
+ const controlErrorResponse = {
1048
+ type: "control_response",
1049
+ response: {
1050
+ subtype: "error",
1051
+ request_id: request.request_id,
1052
+ error: error instanceof Error ? error.message : String(error)
1053
+ }
1054
+ };
1055
+ this.childStdin.write(JSON.stringify(controlErrorResponse) + "\n");
1056
+ } finally {
1057
+ this.cancelControllers.delete(request.request_id);
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Handle control cancel requests
1062
+ * Replicates the exact logic from the SDK's handleControlCancelRequest method
1063
+ */
1064
+ handleControlCancelRequest(request) {
1065
+ const controller = this.cancelControllers.get(request.request_id);
1066
+ if (controller) {
1067
+ controller.abort();
1068
+ this.cancelControllers.delete(request.request_id);
1069
+ }
1070
+ }
1071
+ /**
1072
+ * Process control requests based on subtype
1073
+ * Replicates the exact logic from the SDK's processControlRequest method
1074
+ */
1075
+ async processControlRequest(request, signal) {
1076
+ if (request.request.subtype === "can_use_tool") {
1077
+ if (!this.canCallTool) {
1078
+ throw new Error("canCallTool callback is not provided.");
1079
+ }
1080
+ return this.canCallTool(request.request.tool_name, request.request.input, {
1081
+ signal
1082
+ });
1083
+ }
1084
+ throw new Error("Unsupported control request subtype: " + request.request.subtype);
1085
+ }
1086
+ /**
1087
+ * Cleanup method to abort all pending control requests
1088
+ */
1089
+ cleanupControllers() {
1090
+ for (const [requestId, controller] of this.cancelControllers.entries()) {
1091
+ controller.abort();
1092
+ this.cancelControllers.delete(requestId);
1093
+ }
1094
+ }
976
1095
  }
977
1096
  function query(config) {
978
1097
  const {
@@ -989,12 +1108,12 @@ function query(config) {
989
1108
  mcpServers,
990
1109
  pathToClaudeCodeExecutable = getDefaultClaudeCodePath(),
991
1110
  permissionMode = "default",
992
- permissionPromptToolName,
993
1111
  continue: continueConversation,
994
1112
  resume,
995
1113
  model,
996
1114
  fallbackModel,
997
- strictMcpConfig
1115
+ strictMcpConfig,
1116
+ canCallTool
998
1117
  } = {}
999
1118
  } = config;
1000
1119
  if (!process.env.CLAUDE_CODE_ENTRYPOINT) {
@@ -1005,7 +1124,12 @@ function query(config) {
1005
1124
  if (appendSystemPrompt) args.push("--append-system-prompt", appendSystemPrompt);
1006
1125
  if (maxTurns) args.push("--max-turns", maxTurns.toString());
1007
1126
  if (model) args.push("--model", model);
1008
- if (permissionPromptToolName) args.push("--permission-prompt-tool", permissionPromptToolName);
1127
+ if (canCallTool) {
1128
+ if (typeof prompt === "string") {
1129
+ throw new Error("canCallTool callback requires --input-format stream-json. Please set prompt as an AsyncIterable.");
1130
+ }
1131
+ args.push("--permission-prompt-tool", "stdio");
1132
+ }
1009
1133
  if (continueConversation) args.push("--continue");
1010
1134
  if (resume) args.push("--resume", resume);
1011
1135
  if (allowedTools.length > 0) args.push("--allowedTools", allowedTools.join(","));
@@ -1069,7 +1193,7 @@ function query(config) {
1069
1193
  }
1070
1194
  });
1071
1195
  });
1072
- const query2 = new Query(childStdin, child.stdout, processExitPromise);
1196
+ const query2 = new Query(childStdin, child.stdout, processExitPromise, canCallTool);
1073
1197
  child.on("error", (error) => {
1074
1198
  if (config.options?.abort?.aborted) {
1075
1199
  query2.setError(new AbortError("Claude Code process aborted by user"));
@@ -1087,17 +1211,48 @@ function query(config) {
1087
1211
  return query2;
1088
1212
  }
1089
1213
 
1090
- async function awaitFileExist(file, timeout = 1e4) {
1091
- const startTime = Date.now();
1092
- while (Date.now() - startTime < timeout) {
1093
- try {
1094
- await access(file);
1095
- return true;
1096
- } catch (e) {
1097
- await delay(1e3);
1098
- }
1214
+ function parseCompact(message) {
1215
+ const trimmed = message.trim();
1216
+ if (trimmed === "/compact") {
1217
+ return {
1218
+ isCompact: true,
1219
+ originalMessage: trimmed
1220
+ };
1099
1221
  }
1100
- return false;
1222
+ if (trimmed.startsWith("/compact ")) {
1223
+ return {
1224
+ isCompact: true,
1225
+ originalMessage: trimmed
1226
+ };
1227
+ }
1228
+ return {
1229
+ isCompact: false,
1230
+ originalMessage: message
1231
+ };
1232
+ }
1233
+ function parseClear(message) {
1234
+ const trimmed = message.trim();
1235
+ return {
1236
+ isClear: trimmed === "/clear"
1237
+ };
1238
+ }
1239
+ function parseSpecialCommand(message) {
1240
+ const compactResult = parseCompact(message);
1241
+ if (compactResult.isCompact) {
1242
+ return {
1243
+ type: "compact",
1244
+ originalMessage: compactResult.originalMessage
1245
+ };
1246
+ }
1247
+ const clearResult = parseClear(message);
1248
+ if (clearResult.isClear) {
1249
+ return {
1250
+ type: "clear"
1251
+ };
1252
+ }
1253
+ return {
1254
+ type: null
1255
+ };
1101
1256
  }
1102
1257
 
1103
1258
  class PushableAsyncIterable {
@@ -1226,48 +1381,17 @@ class PushableAsyncIterable {
1226
1381
  }
1227
1382
  }
1228
1383
 
1229
- function parseCompact(message) {
1230
- const trimmed = message.trim();
1231
- if (trimmed === "/compact") {
1232
- return {
1233
- isCompact: true,
1234
- originalMessage: trimmed
1235
- };
1236
- }
1237
- if (trimmed.startsWith("/compact ")) {
1238
- return {
1239
- isCompact: true,
1240
- originalMessage: trimmed
1241
- };
1242
- }
1243
- return {
1244
- isCompact: false,
1245
- originalMessage: message
1246
- };
1247
- }
1248
- function parseClear(message) {
1249
- const trimmed = message.trim();
1250
- return {
1251
- isClear: trimmed === "/clear"
1252
- };
1253
- }
1254
- function parseSpecialCommand(message) {
1255
- const compactResult = parseCompact(message);
1256
- if (compactResult.isCompact) {
1257
- return {
1258
- type: "compact",
1259
- originalMessage: compactResult.originalMessage
1260
- };
1261
- }
1262
- const clearResult = parseClear(message);
1263
- if (clearResult.isClear) {
1264
- return {
1265
- type: "clear"
1266
- };
1384
+ async function awaitFileExist(file, timeout = 1e4) {
1385
+ const startTime = Date.now();
1386
+ while (Date.now() - startTime < timeout) {
1387
+ try {
1388
+ await access(file);
1389
+ return true;
1390
+ } catch (e) {
1391
+ await delay(1e3);
1392
+ }
1267
1393
  }
1268
- return {
1269
- type: null
1270
- };
1394
+ return false;
1271
1395
  }
1272
1396
 
1273
1397
  async function claudeRemote(opts) {
@@ -1280,32 +1404,12 @@ async function claudeRemote(opts) {
1280
1404
  process.env[key] = value;
1281
1405
  });
1282
1406
  }
1283
- let response;
1284
- const sdkOptions = {
1285
- cwd: opts.path,
1286
- resume: startFrom ?? void 0,
1287
- mcpServers: opts.mcpServers,
1288
- permissionPromptToolName: opts.permissionPromptToolName,
1289
- permissionMode: opts.permissionMode,
1290
- model: opts.model,
1291
- fallbackModel: opts.fallbackModel,
1292
- customSystemPrompt: opts.customSystemPrompt,
1293
- appendSystemPrompt: opts.appendSystemPrompt,
1294
- allowedTools: opts.allowedTools,
1295
- disallowedTools: opts.disallowedTools,
1296
- executable: "node",
1297
- abort: opts.signal,
1298
- pathToClaudeCodeExecutable: (() => {
1299
- return resolve(join(projectPath(), "scripts", "claude_remote_launcher.cjs"));
1300
- })()
1301
- };
1302
- if (opts.claudeArgs && opts.claudeArgs.length > 0) {
1303
- sdkOptions.executableArgs = [...sdkOptions.executableArgs || [], ...opts.claudeArgs];
1407
+ const initial = await opts.nextMessage();
1408
+ if (!initial) {
1409
+ return;
1304
1410
  }
1305
- logger.debug(`[claudeRemote] Starting query with permission mode: ${opts.permissionMode}, model: ${opts.model || "default"}, fallbackModel: ${opts.fallbackModel || "none"}, customSystemPrompt: ${opts.customSystemPrompt ? "set" : "none"}, appendSystemPrompt: ${opts.appendSystemPrompt ? "set" : "none"}, allowedTools: ${opts.allowedTools ? opts.allowedTools.join(",") : "none"}, disallowedTools: ${opts.disallowedTools ? opts.disallowedTools.join(",") : "none"}`);
1306
- const specialCommand = parseSpecialCommand(opts.message);
1411
+ const specialCommand = parseSpecialCommand(initial.message);
1307
1412
  if (specialCommand.type === "clear") {
1308
- logger.debug("[claudeRemote] /clear command detected - should not reach here, handled in start.ts");
1309
1413
  if (opts.onCompletionEvent) {
1310
1414
  opts.onCompletionEvent("Context was reset");
1311
1415
  }
@@ -1314,23 +1418,33 @@ async function claudeRemote(opts) {
1314
1418
  }
1315
1419
  return;
1316
1420
  }
1421
+ let isCompactCommand = false;
1317
1422
  if (specialCommand.type === "compact") {
1318
1423
  logger.debug("[claudeRemote] /compact command detected - will process as normal but with compaction behavior");
1319
- }
1320
- const isCompactCommand = specialCommand.type === "compact";
1321
- let message = new PushableAsyncIterable();
1322
- message.push({
1323
- type: "user",
1324
- message: {
1325
- role: "user",
1326
- content: opts.message
1424
+ isCompactCommand = true;
1425
+ if (opts.onCompletionEvent) {
1426
+ opts.onCompletionEvent("Compaction started");
1327
1427
  }
1328
- });
1329
- message.end();
1330
- response = query({
1331
- prompt: message,
1332
- options: sdkOptions
1333
- });
1428
+ }
1429
+ let mode = initial.mode;
1430
+ const sdkOptions = {
1431
+ cwd: opts.path,
1432
+ resume: startFrom ?? void 0,
1433
+ mcpServers: opts.mcpServers,
1434
+ permissionMode: initial.mode.permissionMode === "plan" ? "plan" : "default",
1435
+ model: initial.mode.model,
1436
+ fallbackModel: initial.mode.fallbackModel,
1437
+ customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + "\n\n" + systemPrompt : void 0,
1438
+ appendSystemPrompt: initial.mode.appendSystemPrompt ? initial.mode.appendSystemPrompt + "\n\n" + systemPrompt : systemPrompt,
1439
+ allowedTools: initial.mode.allowedTools ? initial.mode.allowedTools.concat(opts.allowedTools) : opts.allowedTools,
1440
+ disallowedTools: initial.mode.disallowedTools,
1441
+ canCallTool: (toolName, input, options) => opts.canCallTool(toolName, input, mode, options),
1442
+ executable: "node",
1443
+ abort: opts.signal,
1444
+ pathToClaudeCodeExecutable: (() => {
1445
+ return resolve(join(projectPath(), "scripts", "claude_remote_launcher.cjs"));
1446
+ })()
1447
+ };
1334
1448
  let thinking = false;
1335
1449
  const updateThinking = (newThinking) => {
1336
1450
  if (thinking !== newThinking) {
@@ -1341,15 +1455,27 @@ async function claudeRemote(opts) {
1341
1455
  }
1342
1456
  }
1343
1457
  };
1458
+ let messages = new PushableAsyncIterable();
1459
+ messages.push({
1460
+ type: "user",
1461
+ message: {
1462
+ role: "user",
1463
+ content: initial.message
1464
+ }
1465
+ });
1466
+ const response = query({
1467
+ prompt: messages,
1468
+ options: sdkOptions
1469
+ });
1344
1470
  updateThinking(true);
1345
1471
  try {
1346
1472
  logger.debug(`[claudeRemote] Starting to iterate over response`);
1347
- for await (const message2 of response) {
1348
- logger.debugLargeJson(`[claudeRemote] Message ${message2.type}`, message2);
1349
- opts.onMessage(message2);
1350
- if (message2.type === "system" && message2.subtype === "init") {
1473
+ for await (const message of response) {
1474
+ logger.debugLargeJson(`[claudeRemote] Message ${message.type}`, message);
1475
+ opts.onMessage(message);
1476
+ if (message.type === "system" && message.subtype === "init") {
1351
1477
  updateThinking(true);
1352
- const systemInit = message2;
1478
+ const systemInit = message;
1353
1479
  if (systemInit.session_id) {
1354
1480
  logger.debug(`[claudeRemote] Waiting for session file to be written to disk: ${systemInit.session_id}`);
1355
1481
  const projectDir = getProjectPath(opts.path);
@@ -1357,14 +1483,8 @@ async function claudeRemote(opts) {
1357
1483
  logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`);
1358
1484
  opts.onSessionFound(systemInit.session_id);
1359
1485
  }
1360
- if (isCompactCommand) {
1361
- logger.debug("[claudeRemote] Compaction started");
1362
- if (opts.onCompletionEvent) {
1363
- opts.onCompletionEvent("Compaction started");
1364
- }
1365
- }
1366
1486
  }
1367
- if (message2.type === "result") {
1487
+ if (message.type === "result") {
1368
1488
  updateThinking(false);
1369
1489
  logger.debug("[claudeRemote] Result received, exiting claudeRemote");
1370
1490
  if (isCompactCommand) {
@@ -1372,26 +1492,28 @@ async function claudeRemote(opts) {
1372
1492
  if (opts.onCompletionEvent) {
1373
1493
  opts.onCompletionEvent("Compaction completed");
1374
1494
  }
1495
+ isCompactCommand = false;
1375
1496
  }
1376
- return;
1497
+ const next = await opts.nextMessage();
1498
+ if (!next) {
1499
+ messages.end();
1500
+ return;
1501
+ }
1502
+ mode = next.mode;
1503
+ messages.push({ type: "user", message: { role: "user", content: next.message } });
1377
1504
  }
1378
- if (message2.type === "user") {
1379
- const msg = message2;
1505
+ if (message.type === "user") {
1506
+ const msg = message;
1380
1507
  if (msg.message.role === "user" && Array.isArray(msg.message.content)) {
1381
1508
  for (let c of msg.message.content) {
1382
- if (c.type === "tool_result" && (c.name === "exit_plan_mode" || c.name === "ExitPlanMode")) {
1383
- logger.debug("[claudeRemote] Plan result received, exiting claudeRemote");
1384
- return;
1385
- }
1386
- if (c.type === "tool_result" && c.tool_use_id && opts.responses.has(c.tool_use_id) && !opts.responses.get(c.tool_use_id).approved) {
1387
- logger.debug("[claudeRemote] Tool rejected, exiting claudeRemote");
1509
+ if (c.type === "tool_result" && c.tool_use_id && opts.isAborted(c.tool_use_id)) {
1510
+ logger.debug("[claudeRemote] Tool aborted, exiting claudeRemote");
1388
1511
  return;
1389
1512
  }
1390
1513
  }
1391
1514
  }
1392
1515
  }
1393
1516
  }
1394
- logger.debug(`[claudeRemote] Finished iterating over response`);
1395
1517
  } catch (e) {
1396
1518
  if (e instanceof AbortError) {
1397
1519
  logger.debug(`[claudeRemote] Aborted`);
@@ -1401,71 +1523,11 @@ async function claudeRemote(opts) {
1401
1523
  } finally {
1402
1524
  updateThinking(false);
1403
1525
  }
1404
- logger.debug(`[claudeRemote] Function completed`);
1405
1526
  }
1406
1527
 
1407
1528
  const PLAN_FAKE_REJECT = `User approved plan, but you need to be restarted. STOP IMMEDIATELY TO SWITCH FROM PLAN MODE. DO NOT REPLY TO THIS MESSAGE.`;
1408
1529
  const PLAN_FAKE_RESTART = `PlEaZe Continue with plan.`;
1409
1530
 
1410
- async function startPermissionServerV2(handler) {
1411
- const mcp = new McpServer({
1412
- name: "Permission Server",
1413
- version: "1.0.0",
1414
- description: "A server that allows you to request permissions from the user"
1415
- });
1416
- mcp.registerTool("ask_permission", {
1417
- description: "Request permission to execute a tool",
1418
- title: "Request Permission",
1419
- inputSchema: {
1420
- tool_name: z$1.string().describe("The tool that needs permission"),
1421
- input: z$1.any().describe("The arguments for the tool")
1422
- }
1423
- }, async (args) => {
1424
- const response = await handler({ name: args.tool_name, arguments: args.input });
1425
- logger.debugLargeJson("[permissionServerV2] Response", response);
1426
- const result = response.approved ? { behavior: "allow", updatedInput: args.input || {} } : { behavior: "deny", message: response.reason || `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` };
1427
- return {
1428
- content: [
1429
- {
1430
- type: "text",
1431
- text: JSON.stringify(result)
1432
- }
1433
- ],
1434
- isError: false
1435
- };
1436
- });
1437
- const transport = new StreamableHTTPServerTransport({
1438
- // NOTE: Returning session id here will result in claude
1439
- // sdk spawn to fail with `Invalid Request: Server already initialized`
1440
- sessionIdGenerator: void 0
1441
- });
1442
- await mcp.connect(transport);
1443
- const server = createServer(async (req, res) => {
1444
- try {
1445
- await transport.handleRequest(req, res);
1446
- } catch (error) {
1447
- logger.debug("Error handling request:", error);
1448
- if (!res.headersSent) {
1449
- res.writeHead(500).end();
1450
- }
1451
- }
1452
- });
1453
- const baseUrl = await new Promise((resolve) => {
1454
- server.listen(0, "127.0.0.1", () => {
1455
- const addr = server.address();
1456
- resolve(new URL(`http://127.0.0.1:${addr.port}`));
1457
- });
1458
- });
1459
- return {
1460
- url: baseUrl.toString(),
1461
- toolName: "ask_permission",
1462
- stop: () => {
1463
- mcp.close();
1464
- server.close();
1465
- }
1466
- };
1467
- }
1468
-
1469
1531
  function deepEqual(a, b) {
1470
1532
  if (a === b) return true;
1471
1533
  if (a == null || b == null) return false;
@@ -1477,136 +1539,181 @@ function deepEqual(a, b) {
1477
1539
  if (!keysB.includes(key)) return false;
1478
1540
  if (!deepEqual(a[key], b[key])) return false;
1479
1541
  }
1480
- return true;
1542
+ return true;
1543
+ }
1544
+
1545
+ const STANDARD_TOOLS = {
1546
+ // File operations
1547
+ "Read": "Read File",
1548
+ "Write": "Write File",
1549
+ "Edit": "Edit File",
1550
+ "MultiEdit": "Edit File",
1551
+ "NotebookEdit": "Edit Notebook",
1552
+ // Search and navigation
1553
+ "Glob": "Find Files",
1554
+ "Grep": "Search in Files",
1555
+ "LS": "List Directory",
1556
+ // Command execution
1557
+ "Bash": "Run Command",
1558
+ "BashOutput": "Check Command Output",
1559
+ "KillBash": "Stop Command",
1560
+ // Task management
1561
+ "TodoWrite": "Update Tasks",
1562
+ "TodoRead": "Read Tasks",
1563
+ "Task": "Launch Agent",
1564
+ // Web tools
1565
+ "WebFetch": "Fetch Web Page",
1566
+ "WebSearch": "Search Web",
1567
+ // Special cases
1568
+ "exit_plan_mode": "Execute Plan",
1569
+ "ExitPlanMode": "Execute Plan"
1570
+ };
1571
+ function toTitleCase(str) {
1572
+ return str.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
1573
+ }
1574
+ function getToolName(toolName) {
1575
+ if (STANDARD_TOOLS[toolName]) {
1576
+ return STANDARD_TOOLS[toolName];
1577
+ }
1578
+ if (toolName.startsWith("mcp__")) {
1579
+ const parts = toolName.split("__");
1580
+ if (parts.length >= 3) {
1581
+ const server = toTitleCase(parts[1]);
1582
+ const action = toTitleCase(parts.slice(2).join("_"));
1583
+ return `${server}: ${action}`;
1584
+ }
1585
+ }
1586
+ return toTitleCase(toolName);
1481
1587
  }
1482
1588
 
1483
- async function startPermissionResolver(session) {
1484
- let toolCalls = [];
1485
- let responses = /* @__PURE__ */ new Map();
1486
- let requests = /* @__PURE__ */ new Map();
1487
- let pendingPermissionRequests = [];
1488
- const server = await startPermissionServerV2(async (request) => {
1489
- const id = resolveToolCallId(request.name, request.arguments);
1490
- if (!id) {
1491
- logger.debug(`Tool call ID not yet available for ${request.name}, queueing request`);
1492
- return new Promise((resolve, reject) => {
1493
- const timeout = setTimeout(() => {
1494
- const idx = pendingPermissionRequests.findIndex((p) => p.request === request);
1495
- if (idx !== -1) {
1496
- pendingPermissionRequests.splice(idx, 1);
1497
- reject(new Error(`Timeout: Tool call ID never arrived for ${request.name}`));
1498
- }
1499
- }, 3e4);
1500
- pendingPermissionRequests.push({ request, resolve, reject, timeout });
1501
- });
1502
- }
1503
- return handlePermissionRequest(id, request);
1504
- });
1505
- function handlePermissionRequest(id, request) {
1506
- let promise = new Promise((resolve) => {
1507
- if (request.name === "exit_plan_mode" || request.name === "ExitPlanMode") {
1508
- const wrappedResolve = (response) => {
1509
- if (response.approved) {
1510
- logger.debug("Plan approved - injecting PLAN_FAKE_RESTART");
1511
- if (response.mode && ["default", "acceptEdits", "bypassPermissions"].includes(response.mode)) {
1512
- session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: response.mode });
1513
- } else {
1514
- session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: "default" });
1515
- }
1516
- resolve({ approved: false, reason: PLAN_FAKE_REJECT });
1517
- } else {
1518
- resolve(response);
1519
- }
1520
- };
1521
- requests.set(id, wrappedResolve);
1522
- } else {
1523
- requests.set(id, resolve);
1524
- }
1525
- });
1526
- let timeout = setTimeout(async () => {
1527
- logger.debug("Permission timeout - attempting to interrupt Claude");
1528
- requests.delete(id);
1529
- session.client.updateAgentState((currentState) => {
1530
- const request2 = currentState.requests?.[id];
1531
- if (!request2) return currentState;
1532
- let r = { ...currentState.requests };
1533
- delete r[id];
1534
- return {
1535
- ...currentState,
1536
- requests: r,
1537
- completedRequests: {
1538
- ...currentState.completedRequests,
1539
- [id]: {
1540
- ...request2,
1541
- completedAt: Date.now(),
1542
- status: "canceled",
1543
- reason: "Timeout"
1544
- }
1545
- }
1546
- };
1547
- });
1548
- }, 1e3 * 60 * 4.5);
1549
- logger.debug("Permission request" + id + " " + JSON.stringify(request));
1550
- session.api.push().sendToAllDevices(
1551
- "Permission Request",
1552
- `Claude wants to use ${request.name}`,
1553
- {
1554
- sessionId: session.client.sessionId,
1555
- requestId: id,
1556
- tool: request.name,
1557
- type: "permission_request"
1558
- }
1559
- );
1560
- session.client.updateAgentState((currentState) => ({
1561
- ...currentState,
1562
- requests: {
1563
- ...currentState.requests,
1564
- [id]: {
1565
- tool: request.name,
1566
- arguments: request.arguments,
1567
- createdAt: Date.now()
1589
+ function getToolDescriptor(toolName) {
1590
+ if (toolName === "exit_plan_mode" || toolName === "ExitPlanMode") {
1591
+ return { edit: false, exitPlan: true };
1592
+ }
1593
+ if (toolName === "Edit" || toolName === "MultiEdit" || toolName === "Write" || toolName === "NotebookEdit") {
1594
+ return { edit: true, exitPlan: false };
1595
+ }
1596
+ return { edit: false, exitPlan: false };
1597
+ }
1598
+
1599
+ class PermissionHandler {
1600
+ toolCalls = [];
1601
+ responses = /* @__PURE__ */ new Map();
1602
+ pendingRequests = /* @__PURE__ */ new Map();
1603
+ session;
1604
+ allowedTools = /* @__PURE__ */ new Set();
1605
+ permissionMode = "default";
1606
+ constructor(session) {
1607
+ this.session = session;
1608
+ this.setupClientHandler();
1609
+ }
1610
+ handleModeChange(mode) {
1611
+ this.permissionMode = mode;
1612
+ }
1613
+ /**
1614
+ * Handler response
1615
+ */
1616
+ handlePermissionResponse(response, pending) {
1617
+ if (response.allowTools && response.allowTools.length > 0) {
1618
+ response.allowTools.forEach((tool) => this.allowedTools.add(tool));
1619
+ }
1620
+ if (response.mode) {
1621
+ this.permissionMode = response.mode;
1622
+ }
1623
+ if (pending.toolName === "exit_plan_mode" || pending.toolName === "ExitPlanMode") {
1624
+ logger.debug("Plan mode result received", response);
1625
+ if (response.approved) {
1626
+ logger.debug("Plan approved - injecting PLAN_FAKE_RESTART");
1627
+ if (response.mode && ["default", "acceptEdits", "bypassPermissions"].includes(response.mode)) {
1628
+ this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: response.mode });
1629
+ } else {
1630
+ this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: "default" });
1568
1631
  }
1632
+ pending.resolve({ behavior: "deny", message: PLAN_FAKE_REJECT });
1633
+ } else {
1634
+ pending.resolve({ behavior: "deny", message: response.reason || "Plan rejected" });
1569
1635
  }
1570
- }));
1571
- promise.then(() => clearTimeout(timeout)).catch(() => clearTimeout(timeout));
1572
- return promise;
1573
- }
1574
- session.client.setHandler("permission", async (message) => {
1575
- logger.debug("Permission response" + JSON.stringify(message));
1576
- const id = message.id;
1577
- const resolve = requests.get(id);
1578
- if (resolve) {
1579
- responses.set(id, message);
1580
- resolve({ approved: message.approved, reason: message.reason, mode: message.mode });
1581
- requests.delete(id);
1582
1636
  } else {
1583
- logger.debug("Permission request stale, likely timed out");
1584
- return;
1637
+ const result = response.approved ? { behavior: "allow", updatedInput: pending.input || {} } : { behavior: "deny", message: response.reason || `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` };
1638
+ pending.resolve(result);
1585
1639
  }
1586
- session.client.updateAgentState((currentState) => {
1587
- const request = currentState.requests?.[id];
1588
- if (!request) return currentState;
1589
- let r = { ...currentState.requests };
1590
- delete r[id];
1591
- const isExitPlanModeSuccess = request.tool === "exit_plan_mode" && !message.approved && message.reason === PLAN_FAKE_REJECT;
1592
- return {
1640
+ }
1641
+ /**
1642
+ * Creates the canCallTool callback for the SDK
1643
+ */
1644
+ handleToolCall = async (toolName, input, mode, options) => {
1645
+ if (this.allowedTools.has(toolName)) {
1646
+ return { behavior: "allow", updatedInput: input };
1647
+ }
1648
+ const descriptor = getToolDescriptor(toolName);
1649
+ if (this.permissionMode === "bypassPermissions") {
1650
+ return { behavior: "allow", updatedInput: input };
1651
+ }
1652
+ if (this.permissionMode === "acceptEdits" && descriptor.edit) {
1653
+ return { behavior: "allow", updatedInput: input };
1654
+ }
1655
+ let toolCallId = this.resolveToolCallId(toolName, input);
1656
+ if (!toolCallId) {
1657
+ await delay(1e3);
1658
+ toolCallId = this.resolveToolCallId(toolName, input);
1659
+ if (!toolCallId) {
1660
+ throw new Error(`Could not resolve tool call ID for ${toolName}`);
1661
+ }
1662
+ }
1663
+ return this.handlePermissionRequest(toolCallId, toolName, input, options.signal);
1664
+ };
1665
+ /**
1666
+ * Handles individual permission requests
1667
+ */
1668
+ async handlePermissionRequest(id, toolName, input, signal) {
1669
+ return new Promise((resolve, reject) => {
1670
+ const abortHandler = () => {
1671
+ this.pendingRequests.delete(id);
1672
+ reject(new Error("Permission request aborted"));
1673
+ };
1674
+ signal.addEventListener("abort", abortHandler, { once: true });
1675
+ this.pendingRequests.set(id, {
1676
+ resolve: (result) => {
1677
+ signal.removeEventListener("abort", abortHandler);
1678
+ resolve(result);
1679
+ },
1680
+ reject: (error) => {
1681
+ signal.removeEventListener("abort", abortHandler);
1682
+ reject(error);
1683
+ },
1684
+ toolName,
1685
+ input
1686
+ });
1687
+ this.session.api.push().sendToAllDevices(
1688
+ "Permission Request",
1689
+ `Claude wants to ${getToolName(toolName)}`,
1690
+ {
1691
+ sessionId: this.session.client.sessionId,
1692
+ requestId: id,
1693
+ tool: toolName,
1694
+ type: "permission_request"
1695
+ }
1696
+ );
1697
+ this.session.client.updateAgentState((currentState) => ({
1593
1698
  ...currentState,
1594
- requests: r,
1595
- completedRequests: {
1596
- ...currentState.completedRequests,
1699
+ requests: {
1700
+ ...currentState.requests,
1597
1701
  [id]: {
1598
- ...request,
1599
- completedAt: Date.now(),
1600
- status: isExitPlanModeSuccess ? "approved" : message.approved ? "approved" : "denied",
1601
- reason: isExitPlanModeSuccess ? "Plan approved" : message.reason
1702
+ tool: toolName,
1703
+ arguments: input,
1704
+ createdAt: Date.now()
1602
1705
  }
1603
1706
  }
1604
- };
1707
+ }));
1708
+ logger.debug(`Permission request sent for tool call ${id}: ${toolName}`);
1605
1709
  });
1606
- });
1607
- const resolveToolCallId = (name, args) => {
1608
- for (let i = toolCalls.length - 1; i >= 0; i--) {
1609
- const call = toolCalls[i];
1710
+ }
1711
+ /**
1712
+ * Resolves tool call ID based on tool name and input
1713
+ */
1714
+ resolveToolCallId(name, args) {
1715
+ for (let i = this.toolCalls.length - 1; i >= 0; i--) {
1716
+ const call = this.toolCalls[i];
1610
1717
  if (call.name === name && deepEqual(call.input, args)) {
1611
1718
  if (call.used) {
1612
1719
  return null;
@@ -1616,59 +1723,22 @@ async function startPermissionResolver(session) {
1616
1723
  }
1617
1724
  }
1618
1725
  return null;
1619
- };
1620
- function reset() {
1621
- toolCalls = [];
1622
- requests.clear();
1623
- responses.clear();
1624
- for (const pending of pendingPermissionRequests) {
1625
- clearTimeout(pending.timeout);
1626
- }
1627
- pendingPermissionRequests = [];
1628
- session.client.updateAgentState((currentState) => {
1629
- const pendingRequests = currentState.requests || {};
1630
- const completedRequests = { ...currentState.completedRequests };
1631
- for (const [id, request] of Object.entries(pendingRequests)) {
1632
- completedRequests[id] = {
1633
- ...request,
1634
- completedAt: Date.now(),
1635
- status: "canceled",
1636
- reason: "Session switched to local mode"
1637
- };
1638
- }
1639
- return {
1640
- ...currentState,
1641
- requests: {},
1642
- // Clear all pending requests
1643
- completedRequests
1644
- };
1645
- });
1646
1726
  }
1647
- function onMessage(message) {
1727
+ /**
1728
+ * Handles messages to track tool calls
1729
+ */
1730
+ onMessage(message) {
1648
1731
  if (message.type === "assistant") {
1649
1732
  const assistantMsg = message;
1650
1733
  if (assistantMsg.message && assistantMsg.message.content) {
1651
1734
  for (const block of assistantMsg.message.content) {
1652
1735
  if (block.type === "tool_use") {
1653
- toolCalls.push({
1736
+ this.toolCalls.push({
1654
1737
  id: block.id,
1655
1738
  name: block.name,
1656
1739
  input: block.input,
1657
1740
  used: false
1658
1741
  });
1659
- for (let i = pendingPermissionRequests.length - 1; i >= 0; i--) {
1660
- const pending = pendingPermissionRequests[i];
1661
- if (pending.request.name === block.name && deepEqual(pending.request.arguments, block.input)) {
1662
- logger.debug(`Resolving pending permission request for ${block.name} with ID ${block.id}`);
1663
- clearTimeout(pending.timeout);
1664
- pendingPermissionRequests.splice(i, 1);
1665
- handlePermissionRequest(block.id, pending.request).then(
1666
- pending.resolve,
1667
- pending.reject
1668
- );
1669
- break;
1670
- }
1671
- }
1672
1742
  }
1673
1743
  }
1674
1744
  }
@@ -1678,7 +1748,7 @@ async function startPermissionResolver(session) {
1678
1748
  if (userMsg.message && userMsg.message.content && Array.isArray(userMsg.message.content)) {
1679
1749
  for (const block of userMsg.message.content) {
1680
1750
  if (block.type === "tool_result" && block.tool_use_id) {
1681
- const toolCall = toolCalls.find((tc) => tc.id === block.tool_use_id);
1751
+ const toolCall = this.toolCalls.find((tc) => tc.id === block.tool_use_id);
1682
1752
  if (toolCall && !toolCall.used) {
1683
1753
  toolCall.used = true;
1684
1754
  }
@@ -1687,12 +1757,92 @@ async function startPermissionResolver(session) {
1687
1757
  }
1688
1758
  }
1689
1759
  }
1690
- return {
1691
- server,
1692
- reset,
1693
- onMessage,
1694
- responses
1695
- };
1760
+ /**
1761
+ * Checks if a tool call is rejected
1762
+ */
1763
+ isAborted(toolCallId) {
1764
+ if (this.responses.get(toolCallId)?.approved === false) {
1765
+ return true;
1766
+ }
1767
+ const toolCall = this.toolCalls.find((tc) => tc.id === toolCallId);
1768
+ if (toolCall && (toolCall.name === "exit_plan_mode" || toolCall.name === "ExitPlanMode")) {
1769
+ return true;
1770
+ }
1771
+ return false;
1772
+ }
1773
+ /**
1774
+ * Resets all state for new sessions
1775
+ */
1776
+ reset() {
1777
+ this.toolCalls = [];
1778
+ this.responses.clear();
1779
+ for (const [, pending] of this.pendingRequests.entries()) {
1780
+ pending.reject(new Error("Session reset"));
1781
+ }
1782
+ this.pendingRequests.clear();
1783
+ this.session.client.updateAgentState((currentState) => {
1784
+ const pendingRequests = currentState.requests || {};
1785
+ const completedRequests = { ...currentState.completedRequests };
1786
+ for (const [id, request] of Object.entries(pendingRequests)) {
1787
+ completedRequests[id] = {
1788
+ ...request,
1789
+ completedAt: Date.now(),
1790
+ status: "canceled",
1791
+ reason: "Session switched to local mode"
1792
+ };
1793
+ }
1794
+ return {
1795
+ ...currentState,
1796
+ requests: {},
1797
+ // Clear all pending requests
1798
+ completedRequests
1799
+ };
1800
+ });
1801
+ }
1802
+ /**
1803
+ * Sets up the client handler for permission responses
1804
+ */
1805
+ setupClientHandler() {
1806
+ this.session.client.setHandler("permission", async (message) => {
1807
+ logger.debug(`Permission response: ${JSON.stringify(message)}`);
1808
+ const id = message.id;
1809
+ const pending = this.pendingRequests.get(id);
1810
+ if (!pending) {
1811
+ logger.debug("Permission request not found or already resolved");
1812
+ return;
1813
+ }
1814
+ this.responses.set(id, { ...message, receivedAt: Date.now() });
1815
+ this.pendingRequests.delete(id);
1816
+ this.handlePermissionResponse(message, pending);
1817
+ this.session.client.updateAgentState((currentState) => {
1818
+ const request = currentState.requests?.[id];
1819
+ if (!request) return currentState;
1820
+ let r = { ...currentState.requests };
1821
+ delete r[id];
1822
+ return {
1823
+ ...currentState,
1824
+ requests: r,
1825
+ completedRequests: {
1826
+ ...currentState.completedRequests,
1827
+ [id]: {
1828
+ ...request,
1829
+ completedAt: Date.now(),
1830
+ status: message.approved ? "approved" : "denied",
1831
+ reason: message.reason,
1832
+ mode: message.mode,
1833
+ allowTools: message.allowTools
1834
+ }
1835
+ }
1836
+ };
1837
+ });
1838
+ });
1839
+ }
1840
+ /**
1841
+ * Gets the responses map (for compatibility with existing code)
1842
+ */
1843
+ getResponses() {
1844
+ return this.responses;
1845
+ }
1696
1846
  }
1697
1847
 
1698
1848
  function formatClaudeMessageForInk(message, messageBuffer, onAssistantResult) {
@@ -2066,15 +2216,6 @@ async function claudeRemoteLauncher(session) {
2066
2216
  }
2067
2217
  process.stdin.setEncoding("utf8");
2068
2218
  }
2069
- const scanner = await createSessionScanner({
2070
- sessionId: session.sessionId,
2071
- workingDirectory: session.path,
2072
- onMessage: (message) => {
2073
- if (message.type === "summary") {
2074
- session.client.sendClaudeSessionMessage(message);
2075
- }
2076
- }
2077
- });
2078
2219
  let exitReason = null;
2079
2220
  let abortController = null;
2080
2221
  let abortFuture = null;
@@ -2097,17 +2238,17 @@ async function claudeRemoteLauncher(session) {
2097
2238
  }
2098
2239
  session.client.setHandler("abort", doAbort);
2099
2240
  session.client.setHandler("switch", doSwitch);
2100
- const permissions = await startPermissionResolver(session);
2241
+ const permissionHandler = new PermissionHandler(session);
2101
2242
  const sdkToLogConverter = new SDKToLogConverter({
2102
2243
  sessionId: session.sessionId || "unknown",
2103
2244
  cwd: session.path,
2104
2245
  version: process.env.npm_package_version
2105
- }, permissions.responses);
2246
+ }, permissionHandler.getResponses());
2106
2247
  let planModeToolCalls = /* @__PURE__ */ new Set();
2107
2248
  let ongoingToolCalls = /* @__PURE__ */ new Map();
2108
2249
  function onMessage(message) {
2109
2250
  formatClaudeMessageForInk(message, messageBuffer);
2110
- permissions.onMessage(message);
2251
+ permissionHandler.onMessage(message);
2111
2252
  if (message.type === "assistant") {
2112
2253
  let umessage = message;
2113
2254
  if (umessage.message.content && Array.isArray(umessage.message.content)) {
@@ -2171,6 +2312,32 @@ async function claudeRemoteLauncher(session) {
2171
2312
  }
2172
2313
  const logMessage = sdkToLogConverter.convert(msg);
2173
2314
  if (logMessage) {
2315
+ if (logMessage.type === "user" && logMessage.message?.content) {
2316
+ const content = Array.isArray(logMessage.message.content) ? logMessage.message.content : [];
2317
+ for (let i = 0; i < content.length; i++) {
2318
+ const c = content[i];
2319
+ if (c.type === "tool_result" && c.tool_use_id) {
2320
+ const responses = permissionHandler.getResponses();
2321
+ const response = responses.get(c.tool_use_id);
2322
+ if (response) {
2323
+ const permissions = {
2324
+ date: response.receivedAt || Date.now(),
2325
+ result: response.approved ? "approved" : "denied"
2326
+ };
2327
+ if (response.mode) {
2328
+ permissions.mode = response.mode;
2329
+ }
2330
+ if (response.allowTools && response.allowTools.length > 0) {
2331
+ permissions.allowedTools = response.allowTools;
2332
+ }
2333
+ content[i] = {
2334
+ ...c,
2335
+ permissions
2336
+ };
2337
+ }
2338
+ }
2339
+ }
2340
+ }
2174
2341
  if (logMessage.type !== "system") {
2175
2342
  session.client.sendClaudeSessionMessage(logMessage);
2176
2343
  }
@@ -2190,58 +2357,57 @@ async function claudeRemoteLauncher(session) {
2190
2357
  }
2191
2358
  }
2192
2359
  try {
2360
+ let pending = null;
2193
2361
  while (!exitReason) {
2194
- logger.debug("[remote]: fetch next message");
2195
- abortController = new AbortController();
2196
- abortFuture = new Future();
2197
- const messageData = await session.queue.waitForMessagesAndGetAsString(abortController.signal);
2198
- if (!messageData || abortController.signal.aborted) {
2199
- logger.debug("[remote]: fetch next message done: no message or aborted");
2200
- abortFuture?.resolve(void 0);
2201
- if (exitReason) {
2202
- return exitReason;
2203
- } else {
2204
- continue;
2205
- }
2206
- }
2207
- logger.debug("[remote]: fetch next message done: message received");
2208
- abortFuture?.resolve(void 0);
2209
- abortFuture = null;
2210
- abortController = null;
2211
2362
  logger.debug("[remote]: launch");
2212
2363
  messageBuffer.addMessage("\u2550".repeat(40), "status");
2213
2364
  messageBuffer.addMessage("Starting new Claude session...", "status");
2214
- abortController = new AbortController();
2365
+ const controller = new AbortController();
2366
+ abortController = controller;
2215
2367
  abortFuture = new Future();
2216
- permissions.reset();
2368
+ permissionHandler.reset();
2217
2369
  sdkToLogConverter.resetParentChain();
2370
+ let modeHash = null;
2371
+ let mode = null;
2218
2372
  try {
2219
2373
  await claudeRemote({
2220
2374
  sessionId: session.sessionId,
2221
2375
  path: session.path,
2222
- responses: permissions.responses,
2223
- mcpServers: {
2224
- ...session.mcpServers,
2225
- permission: {
2226
- type: "http",
2227
- url: permissions.server.url
2376
+ allowedTools: session.allowedTools ?? [],
2377
+ mcpServers: session.mcpServers,
2378
+ canCallTool: permissionHandler.handleToolCall,
2379
+ isAborted: (toolCallId) => {
2380
+ return permissionHandler.isAborted(toolCallId);
2381
+ },
2382
+ nextMessage: async () => {
2383
+ if (pending) {
2384
+ let p = pending;
2385
+ pending = null;
2386
+ permissionHandler.handleModeChange(p.mode.permissionMode);
2387
+ return p;
2388
+ }
2389
+ let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal);
2390
+ if (msg) {
2391
+ if (modeHash && msg.hash !== modeHash || msg.isolate) {
2392
+ logger.debug("[remote]: mode has changed, pending message");
2393
+ pending = msg;
2394
+ return null;
2395
+ }
2396
+ modeHash = msg.hash;
2397
+ mode = msg.mode;
2398
+ permissionHandler.handleModeChange(mode.permissionMode);
2399
+ return {
2400
+ message: msg.message,
2401
+ mode: msg.mode
2402
+ };
2228
2403
  }
2404
+ return null;
2229
2405
  },
2230
- permissionPromptToolName: "mcp__permission__" + permissions.server.toolName,
2231
- permissionMode: messageData.mode.permissionMode,
2232
- model: messageData.mode.model,
2233
- fallbackModel: messageData.mode.fallbackModel,
2234
- customSystemPrompt: messageData.mode.customSystemPrompt,
2235
- appendSystemPrompt: messageData.mode.appendSystemPrompt,
2236
- allowedTools: messageData.mode.allowedTools,
2237
- disallowedTools: messageData.mode.disallowedTools,
2238
2406
  onSessionFound: (sessionId) => {
2239
2407
  sdkToLogConverter.updateSessionId(sessionId);
2240
2408
  session.onSessionFound(sessionId);
2241
- scanner.onNewSession(sessionId);
2242
2409
  },
2243
2410
  onThinkingChange: session.onThinkingChange,
2244
- message: messageData.message,
2245
2411
  claudeEnvVars: session.claudeEnvVars,
2246
2412
  claudeArgs: session.claudeArgs,
2247
2413
  onMessage,
@@ -2259,11 +2425,13 @@ async function claudeRemoteLauncher(session) {
2259
2425
  session.client.sendSessionEvent({ type: "message", message: "Aborted by user" });
2260
2426
  }
2261
2427
  } catch (e) {
2428
+ logger.debug("[remote]: launch error", e);
2262
2429
  if (!exitReason) {
2263
2430
  session.client.sendSessionEvent({ type: "message", message: "Process exited unexpectedly" });
2264
2431
  continue;
2265
2432
  }
2266
2433
  } finally {
2434
+ logger.debug("[remote]: launch finally");
2267
2435
  for (let [toolCallId, { parentToolCallId }] of ongoingToolCalls) {
2268
2436
  const converted = sdkToLogConverter.generateInterruptedToolResult(toolCallId, parentToolCallId);
2269
2437
  if (converted) {
@@ -2276,11 +2444,13 @@ async function claudeRemoteLauncher(session) {
2276
2444
  abortFuture?.resolve(void 0);
2277
2445
  abortFuture = null;
2278
2446
  logger.debug("[remote]: launch done");
2279
- permissions.reset();
2447
+ permissionHandler.reset();
2448
+ modeHash = null;
2449
+ mode = null;
2280
2450
  }
2281
2451
  }
2282
2452
  } finally {
2283
- permissions.server.stop();
2453
+ permissionHandler.reset();
2284
2454
  process.stdin.off("data", abort);
2285
2455
  if (process.stdin.isTTY) {
2286
2456
  process.stdin.setRawMode(false);
@@ -2292,7 +2462,6 @@ async function claudeRemoteLauncher(session) {
2292
2462
  if (abortFuture) {
2293
2463
  abortFuture.resolve(void 0);
2294
2464
  }
2295
- await scanner.cleanup();
2296
2465
  }
2297
2466
  return exitReason || "exit";
2298
2467
  }
@@ -2309,6 +2478,7 @@ async function loop(opts) {
2309
2478
  mcpServers: opts.mcpServers,
2310
2479
  logPath,
2311
2480
  messageQueue: opts.messageQueue,
2481
+ allowedTools: opts.allowedTools,
2312
2482
  onModeChange: opts.onModeChange
2313
2483
  });
2314
2484
  if (opts.onSessionReady) {
@@ -2343,7 +2513,7 @@ async function loop(opts) {
2343
2513
  }
2344
2514
 
2345
2515
  var name = "happy-coder";
2346
- var version = "0.7.2";
2516
+ var version = "0.9.0-0";
2347
2517
  var description = "Claude Code session sharing CLI";
2348
2518
  var author = "Kirill Dubovitskiy";
2349
2519
  var license = "MIT";
@@ -2393,18 +2563,14 @@ var scripts = {
2393
2563
  test: "yarn build && vitest run",
2394
2564
  "test:watch": "vitest",
2395
2565
  "test:integration-test-env": "yarn build && tsx --env-file .env.integration-test node_modules/.bin/vitest run",
2396
- dev: "yarn build && npx tsx src/index.ts",
2566
+ dev: "yarn build && DEBUG=1 npx tsx src/index.ts",
2397
2567
  "dev:local-server": "yarn build && tsx --env-file .env.dev-local-server src/index.ts",
2398
2568
  "dev:integration-test-env": "yarn build && tsx --env-file .env.integration-test src/index.ts",
2399
2569
  prepublishOnly: "yarn build && yarn test",
2400
- "minor:publish": "yarn build && npm version minor && npm publish",
2401
- "patch:publish": "yarn build && npm version patch && npm publish",
2402
- "version:prerelease": "yarn build && npm version prerelease --preid=beta",
2403
- "publish:prerelease": "npm publish --tag beta",
2404
- "beta:publish": "yarn version:prerelease && yarn publish:prerelease"
2570
+ release: "release-it"
2405
2571
  };
2406
2572
  var dependencies = {
2407
- "@anthropic-ai/claude-code": "^1.0.73",
2573
+ "@anthropic-ai/claude-code": "^1.0.89",
2408
2574
  "@anthropic-ai/sdk": "^0.56.0",
2409
2575
  "@modelcontextprotocol/sdk": "^1.15.1",
2410
2576
  "@stablelib/base64": "^2.0.1",
@@ -2433,6 +2599,7 @@ var devDependencies = {
2433
2599
  eslint: "^9",
2434
2600
  "eslint-config-prettier": "^10",
2435
2601
  pkgroll: "^2.14.2",
2602
+ "release-it": "^19.0.4",
2436
2603
  shx: "^0.3.3",
2437
2604
  "ts-node": "^10",
2438
2605
  tsx: "^4.20.3",
@@ -2440,7 +2607,12 @@ var devDependencies = {
2440
2607
  vitest: "^3.2.4"
2441
2608
  };
2442
2609
  var resolutions = {
2443
- "whatwg-url": "14.2.0"
2610
+ "whatwg-url": "14.2.0",
2611
+ "parse-path": "7.0.3",
2612
+ "@types/parse-path": "7.0.3"
2613
+ };
2614
+ var publishConfig = {
2615
+ registry: "https://registry.npmjs.org"
2444
2616
  };
2445
2617
  var packageManager = "yarn@1.22.22";
2446
2618
  var packageJson = {
@@ -2463,6 +2635,7 @@ var packageJson = {
2463
2635
  dependencies: dependencies,
2464
2636
  devDependencies: devDependencies,
2465
2637
  resolutions: resolutions,
2638
+ publishConfig: publishConfig,
2466
2639
  packageManager: packageManager
2467
2640
  };
2468
2641
 
@@ -2849,15 +3022,17 @@ async function clearDaemonState() {
2849
3022
  }
2850
3023
 
2851
3024
  class MessageQueue2 {
2852
- constructor(modeHasher) {
2853
- this.modeHasher = modeHasher;
2854
- logger.debug(`[MessageQueue2] Initialized`);
2855
- }
2856
3025
  queue = [];
2857
3026
  // Made public for testing
2858
3027
  waiter = null;
2859
3028
  closed = false;
2860
3029
  onMessageHandler = null;
3030
+ modeHasher;
3031
+ constructor(modeHasher, onMessageHandler = null) {
3032
+ this.modeHasher = modeHasher;
3033
+ this.onMessageHandler = onMessageHandler;
3034
+ logger.debug(`[MessageQueue2] Initialized`);
3035
+ }
2861
3036
  /**
2862
3037
  * Set a handler that will be called when a message arrives
2863
3038
  */
@@ -3032,6 +3207,7 @@ class MessageQueue2 {
3032
3207
  const firstItem = this.queue[0];
3033
3208
  const sameModeMessages = [];
3034
3209
  let mode = firstItem.mode;
3210
+ let isolate = firstItem.isolate ?? false;
3035
3211
  const targetModeHash = firstItem.modeHash;
3036
3212
  if (firstItem.isolate) {
3037
3213
  const item = this.queue.shift();
@@ -3047,7 +3223,9 @@ class MessageQueue2 {
3047
3223
  const combinedMessage = sameModeMessages.join("\n");
3048
3224
  return {
3049
3225
  message: combinedMessage,
3050
- mode
3226
+ mode,
3227
+ hash: targetModeHash,
3228
+ isolate
3051
3229
  };
3052
3230
  }
3053
3231
  /**
@@ -3942,10 +4120,10 @@ async function doWebAuth(keypair) {
3942
4120
  console.log("\u2713 Browser opened\n");
3943
4121
  console.log("Complete authentication in your browser window.");
3944
4122
  } else {
3945
- console.log("Could not open browser automatically.\n");
3946
- console.log("Please open this URL manually:");
3947
- console.log(webUrl);
4123
+ console.log("Could not open browser automatically.");
3948
4124
  }
4125
+ console.log("\nIf the browser did not open, please copy and paste this URL:");
4126
+ console.log(webUrl);
3949
4127
  console.log("");
3950
4128
  return await waitForAuthentication(keypair);
3951
4129
  }
@@ -4302,6 +4480,88 @@ async function startDaemon() {
4302
4480
  }
4303
4481
  }
4304
4482
 
4483
+ async function startHappyServer(client) {
4484
+ const handler = async (title) => {
4485
+ logger.debug("[happyMCP] Changing title to:", title);
4486
+ try {
4487
+ client.sendClaudeSessionMessage({
4488
+ type: "summary",
4489
+ summary: title,
4490
+ leafUuid: randomUUID()
4491
+ });
4492
+ return { success: true };
4493
+ } catch (error) {
4494
+ return { success: false, error: String(error) };
4495
+ }
4496
+ };
4497
+ const mcp = new McpServer({
4498
+ name: "Happy MCP",
4499
+ version: "1.0.0",
4500
+ description: "Happy CLI MCP server with chat session management tools"
4501
+ });
4502
+ mcp.registerTool("change_title", {
4503
+ description: "Change the title of the current chat session",
4504
+ title: "Change Chat Title",
4505
+ inputSchema: {
4506
+ title: z$1.string().describe("The new title for the chat session")
4507
+ }
4508
+ }, async (args) => {
4509
+ const response = await handler(args.title);
4510
+ logger.debug("[happyMCP] Response:", response);
4511
+ if (response.success) {
4512
+ return {
4513
+ content: [
4514
+ {
4515
+ type: "text",
4516
+ text: `Successfully changed chat title to: "${args.title}"`
4517
+ }
4518
+ ],
4519
+ isError: false
4520
+ };
4521
+ } else {
4522
+ return {
4523
+ content: [
4524
+ {
4525
+ type: "text",
4526
+ text: `Failed to change chat title: ${response.error || "Unknown error"}`
4527
+ }
4528
+ ],
4529
+ isError: true
4530
+ };
4531
+ }
4532
+ });
4533
+ const transport = new StreamableHTTPServerTransport({
4534
+ // NOTE: Returning session id here will result in claude
4535
+ // sdk spawn to fail with `Invalid Request: Server already initialized`
4536
+ sessionIdGenerator: void 0
4537
+ });
4538
+ await mcp.connect(transport);
4539
+ const server = createServer(async (req, res) => {
4540
+ try {
4541
+ await transport.handleRequest(req, res);
4542
+ } catch (error) {
4543
+ logger.debug("Error handling request:", error);
4544
+ if (!res.headersSent) {
4545
+ res.writeHead(500).end();
4546
+ }
4547
+ }
4548
+ });
4549
+ const baseUrl = await new Promise((resolve) => {
4550
+ server.listen(0, "127.0.0.1", () => {
4551
+ const addr = server.address();
4552
+ resolve(new URL(`http://127.0.0.1:${addr.port}`));
4553
+ });
4554
+ });
4555
+ return {
4556
+ url: baseUrl.toString(),
4557
+ toolNames: ["change_title"],
4558
+ stop: () => {
4559
+ mcp.close();
4560
+ server.close();
4561
+ }
4562
+ };
4563
+ }
4564
+
4305
4565
  async function start(credentials, options = {}) {
4306
4566
  const workingDirectory = process.cwd();
4307
4567
  const sessionTag = randomUUID();
@@ -4361,6 +4621,8 @@ async function start(credentials, options = {}) {
4361
4621
  }
4362
4622
  });
4363
4623
  const session = api.sessionSyncClient(response);
4624
+ const happyServer = await startHappyServer(session);
4625
+ logger.debug(`[START] Happy MCP server started at ${happyServer.url}`);
4364
4626
  const logPath = await logger.logFilePathPromise;
4365
4627
  logger.infoDeveloper(`Session: ${response.id}`);
4366
4628
  logger.infoDeveloper(`Logs: ${logPath}`);
@@ -4372,7 +4634,15 @@ async function start(credentials, options = {}) {
4372
4634
  if (caffeinateStarted) {
4373
4635
  logger.infoDeveloper("Sleep prevention enabled (macOS)");
4374
4636
  }
4375
- const messageQueue = new MessageQueue2((mode) => hashObject(mode));
4637
+ const messageQueue = new MessageQueue2((mode) => hashObject({
4638
+ isPlan: mode.permissionMode === "plan",
4639
+ model: mode.model,
4640
+ fallbackModel: mode.fallbackModel,
4641
+ customSystemPrompt: mode.customSystemPrompt,
4642
+ appendSystemPrompt: mode.appendSystemPrompt,
4643
+ allowedTools: mode.allowedTools,
4644
+ disallowedTools: mode.disallowedTools
4645
+ }));
4376
4646
  registerHandlers(session);
4377
4647
  let currentPermissionMode = options.permissionMode;
4378
4648
  let currentModel = options.model;
@@ -4495,6 +4765,7 @@ async function start(credentials, options = {}) {
4495
4765
  await session.close();
4496
4766
  }
4497
4767
  stopCaffeinate();
4768
+ happyServer.stop();
4498
4769
  logger.debug("[START] Cleanup complete, exiting");
4499
4770
  process.exit(0);
4500
4771
  } catch (error) {
@@ -4519,6 +4790,7 @@ async function start(credentials, options = {}) {
4519
4790
  startingMode: options.startingMode,
4520
4791
  messageQueue,
4521
4792
  api,
4793
+ allowedTools: happyServer.toolNames.map((toolName) => `mcp__happy__${toolName}`),
4522
4794
  onModeChange: (newMode) => {
4523
4795
  session.sendSessionEvent({ type: "switch", mode: newMode });
4524
4796
  session.updateAgentState((currentState) => ({
@@ -4528,7 +4800,12 @@ async function start(credentials, options = {}) {
4528
4800
  },
4529
4801
  onSessionReady: (sessionInstance) => {
4530
4802
  },
4531
- mcpServers: {},
4803
+ mcpServers: {
4804
+ "happy": {
4805
+ type: "http",
4806
+ url: happyServer.url
4807
+ }
4808
+ },
4532
4809
  session,
4533
4810
  claudeEnvVars: options.claudeEnvVars,
4534
4811
  claudeArgs: options.claudeArgs
@@ -4540,28 +4817,11 @@ async function start(credentials, options = {}) {
4540
4817
  await session.close();
4541
4818
  stopCaffeinate();
4542
4819
  logger.debug("Stopped sleep prevention");
4820
+ happyServer.stop();
4821
+ logger.debug("Stopped Happy MCP server");
4543
4822
  process.exit(0);
4544
4823
  }
4545
4824
 
4546
- function trimIdent(text) {
4547
- const lines = text.split("\n");
4548
- while (lines.length > 0 && lines[0].trim() === "") {
4549
- lines.shift();
4550
- }
4551
- while (lines.length > 0 && lines[lines.length - 1].trim() === "") {
4552
- lines.pop();
4553
- }
4554
- const minSpaces = lines.reduce((min, line) => {
4555
- if (line.trim() === "") {
4556
- return min;
4557
- }
4558
- const leadingSpaces = line.match(/^\s*/)[0].length;
4559
- return Math.min(min, leadingSpaces);
4560
- }, Infinity);
4561
- const trimmedLines = lines.map((line) => line.slice(minSpaces));
4562
- return trimmedLines.join("\n");
4563
- }
4564
-
4565
4825
  const PLIST_LABEL$1 = "com.happy-cli.daemon";
4566
4826
  const PLIST_FILE$1 = `/Library/LaunchDaemons/${PLIST_LABEL$1}.plist`;
4567
4827
  async function install$1() {