happy-coder 0.6.3 → 0.7.1-beta.1

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
@@ -1,21 +1,21 @@
1
1
  import chalk from 'chalk';
2
- import { l as logger, d as backoff, e as delay, R as RawJSONLinesSchema, c as configuration, f as encodeBase64, A as ApiClient, g as encodeBase64Url, h as decodeBase64, j as encrypt, b as initializeConfiguration, i as initLoggerWithGlobalConfiguration } from './types-Dz5kZrVh.mjs';
2
+ import { l as logger, b as backoff, d as delay, R as RawJSONLinesSchema, c as configuration, e as encodeBase64, A as ApiClient, f as encodeBase64Url, g as decodeBase64 } from './types-BZC9-exR.mjs';
3
3
  import { randomUUID, randomBytes } from 'node:crypto';
4
4
  import { spawn, execSync } from 'node:child_process';
5
5
  import { resolve, join, dirname as dirname$1 } from 'node:path';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { fileURLToPath as fileURLToPath$1 } from 'node:url';
8
- import { existsSync, readFileSync, mkdirSync, watch, rmSync } from 'node:fs';
8
+ import { existsSync, readFileSync, mkdirSync, watch, constants, readdirSync, statSync, rmSync } from 'node:fs';
9
9
  import os, { homedir } from 'node:os';
10
10
  import { dirname, resolve as resolve$1, join as join$1 } from 'path';
11
11
  import { fileURLToPath } from 'url';
12
- import { readFile, mkdir, writeFile as writeFile$1 } from 'node:fs/promises';
12
+ import { readFile, unlink, mkdir, writeFile as writeFile$1, open, stat as stat$1, rename } from 'node:fs/promises';
13
13
  import { watch as watch$1, access, readFile as readFile$1, stat, writeFile, readdir } from 'fs/promises';
14
14
  import { useStdout, useInput, Box, Text, render } from 'ink';
15
15
  import React, { useState, useRef, useEffect, useCallback } from 'react';
16
16
  import axios from 'axios';
17
- import { EventEmitter } from 'node:events';
18
- import { io } from 'socket.io-client';
17
+ import 'node:events';
18
+ import 'socket.io-client';
19
19
  import tweetnacl from 'tweetnacl';
20
20
  import 'expo-server-sdk';
21
21
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -25,11 +25,13 @@ import * as z from 'zod';
25
25
  import { z as z$1 } from 'zod';
26
26
  import { spawn as spawn$1, exec, execSync as execSync$1 } from 'child_process';
27
27
  import { promisify } from 'util';
28
- import crypto, { createHash } from 'crypto';
28
+ import { createHash } from 'crypto';
29
29
  import qrcode from 'qrcode-terminal';
30
- import open from 'open';
31
- import { existsSync as existsSync$1, readFileSync as readFileSync$1, writeFileSync, unlinkSync, mkdirSync as mkdirSync$1, chmodSync } from 'fs';
32
- import { hostname, homedir as homedir$1 } from 'os';
30
+ import open$1 from 'open';
31
+ import fastify from 'fastify';
32
+ import { validatorCompiler, serializerCompiler } from 'fastify-type-provider-zod';
33
+ import os$1 from 'os';
34
+ import { existsSync as existsSync$1, writeFileSync, chmodSync, unlinkSync } from 'fs';
33
35
 
34
36
  class Session {
35
37
  path;
@@ -72,11 +74,19 @@ class Session {
72
74
  onSessionFound = (sessionId) => {
73
75
  this.sessionId = sessionId;
74
76
  };
77
+ /**
78
+ * Clear the current session ID (used by /clear command)
79
+ */
80
+ clearSessionId = () => {
81
+ this.sessionId = null;
82
+ logger.debug("[Session] Session ID cleared");
83
+ };
75
84
  }
76
85
 
77
86
  function getProjectPath(workingDirectory) {
78
87
  const projectId = resolve(workingDirectory).replace(/[\\\/\.:]/g, "-");
79
- return join(homedir(), ".claude", "projects", projectId);
88
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
89
+ return join(claudeConfigDir, "projects", projectId);
80
90
  }
81
91
 
82
92
  function claudeCheckSession(sessionId, path) {
@@ -1216,6 +1226,50 @@ class PushableAsyncIterable {
1216
1226
  }
1217
1227
  }
1218
1228
 
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
+ };
1267
+ }
1268
+ return {
1269
+ type: null
1270
+ };
1271
+ }
1272
+
1219
1273
  async function claudeRemote(opts) {
1220
1274
  let startFrom = opts.sessionId;
1221
1275
  if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) {
@@ -1249,6 +1303,21 @@ async function claudeRemote(opts) {
1249
1303
  sdkOptions.executableArgs = [...sdkOptions.executableArgs || [], ...opts.claudeArgs];
1250
1304
  }
1251
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);
1307
+ if (specialCommand.type === "clear") {
1308
+ logger.debug("[claudeRemote] /clear command detected - should not reach here, handled in start.ts");
1309
+ if (opts.onCompletionEvent) {
1310
+ opts.onCompletionEvent("Context was reset");
1311
+ }
1312
+ if (opts.onSessionReset) {
1313
+ opts.onSessionReset();
1314
+ }
1315
+ return;
1316
+ }
1317
+ if (specialCommand.type === "compact") {
1318
+ logger.debug("[claudeRemote] /compact command detected - will process as normal but with compaction behavior");
1319
+ }
1320
+ const isCompactCommand = specialCommand.type === "compact";
1252
1321
  let message = new PushableAsyncIterable();
1253
1322
  message.push({
1254
1323
  type: "user",
@@ -1288,10 +1357,22 @@ async function claudeRemote(opts) {
1288
1357
  logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`);
1289
1358
  opts.onSessionFound(systemInit.session_id);
1290
1359
  }
1360
+ if (isCompactCommand) {
1361
+ logger.debug("[claudeRemote] Compaction started");
1362
+ if (opts.onCompletionEvent) {
1363
+ opts.onCompletionEvent("Compaction started");
1364
+ }
1365
+ }
1291
1366
  }
1292
1367
  if (message2.type === "result") {
1293
1368
  updateThinking(false);
1294
1369
  logger.debug("[claudeRemote] Result received, exiting claudeRemote");
1370
+ if (isCompactCommand) {
1371
+ logger.debug("[claudeRemote] Compaction completed");
1372
+ if (opts.onCompletionEvent) {
1373
+ opts.onCompletionEvent("Compaction completed");
1374
+ }
1375
+ }
1295
1376
  return;
1296
1377
  }
1297
1378
  if (message2.type === "user") {
@@ -1952,31 +2033,39 @@ class SDKToLogConverter {
1952
2033
  }
1953
2034
 
1954
2035
  async function claudeRemoteLauncher(session) {
2036
+ logger.debug("[claudeRemoteLauncher] Starting remote launcher");
2037
+ const hasTTY = process.stdout.isTTY && process.stdin.isTTY;
2038
+ logger.debug(`[claudeRemoteLauncher] TTY available: ${hasTTY}`);
1955
2039
  let messageBuffer = new MessageBuffer();
1956
- console.clear();
1957
- let inkInstance = render(React.createElement(RemoteModeDisplay, {
1958
- messageBuffer,
1959
- logPath: process.env.DEBUG ? session.logPath : void 0,
1960
- onExit: async () => {
1961
- logger.debug("[remote]: Exiting client via Ctrl-C");
1962
- if (!exitReason) {
1963
- exitReason = "exit";
2040
+ let inkInstance = null;
2041
+ if (hasTTY) {
2042
+ console.clear();
2043
+ inkInstance = render(React.createElement(RemoteModeDisplay, {
2044
+ messageBuffer,
2045
+ logPath: process.env.DEBUG ? session.logPath : void 0,
2046
+ onExit: async () => {
2047
+ logger.debug("[remote]: Exiting client via Ctrl-C");
2048
+ if (!exitReason) {
2049
+ exitReason = "exit";
2050
+ }
2051
+ await abort();
2052
+ },
2053
+ onSwitchToLocal: () => {
2054
+ logger.debug("[remote]: Switching to local mode via double space");
2055
+ doSwitch();
1964
2056
  }
1965
- await abort();
1966
- },
1967
- onSwitchToLocal: () => {
1968
- logger.debug("[remote]: Switching to local mode via double space");
1969
- doSwitch();
2057
+ }), {
2058
+ exitOnCtrlC: false,
2059
+ patchConsole: false
2060
+ });
2061
+ }
2062
+ if (hasTTY) {
2063
+ process.stdin.resume();
2064
+ if (process.stdin.isTTY) {
2065
+ process.stdin.setRawMode(true);
1970
2066
  }
1971
- }), {
1972
- exitOnCtrlC: false,
1973
- patchConsole: false
1974
- });
1975
- process.stdin.resume();
1976
- if (process.stdin.isTTY) {
1977
- process.stdin.setRawMode(true);
2067
+ process.stdin.setEncoding("utf8");
1978
2068
  }
1979
- process.stdin.setEncoding("utf8");
1980
2069
  const scanner = await createSessionScanner({
1981
2070
  sessionId: session.sessionId,
1982
2071
  workingDirectory: session.path,
@@ -2156,6 +2245,14 @@ async function claudeRemoteLauncher(session) {
2156
2245
  claudeEnvVars: session.claudeEnvVars,
2157
2246
  claudeArgs: session.claudeArgs,
2158
2247
  onMessage,
2248
+ onCompletionEvent: (message) => {
2249
+ logger.debug(`[remote]: Completion event: ${message}`);
2250
+ session.client.sendSessionEvent({ type: "message", message });
2251
+ },
2252
+ onSessionReset: () => {
2253
+ logger.debug("[remote]: Session reset");
2254
+ session.clearSessionId();
2255
+ },
2159
2256
  signal: abortController.signal
2160
2257
  });
2161
2258
  if (!exitReason && abortController.signal.aborted) {
@@ -2188,7 +2285,9 @@ async function claudeRemoteLauncher(session) {
2188
2285
  if (process.stdin.isTTY) {
2189
2286
  process.stdin.setRawMode(false);
2190
2287
  }
2191
- inkInstance.unmount();
2288
+ if (inkInstance) {
2289
+ inkInstance.unmount();
2290
+ }
2192
2291
  messageBuffer.clear();
2193
2292
  if (abortFuture) {
2194
2293
  abortFuture.resolve(void 0);
@@ -2212,6 +2311,9 @@ async function loop(opts) {
2212
2311
  messageQueue: opts.messageQueue,
2213
2312
  onModeChange: opts.onModeChange
2214
2313
  });
2314
+ if (opts.onSessionReady) {
2315
+ opts.onSessionReady(session);
2316
+ }
2215
2317
  let mode = opts.startingMode ?? "local";
2216
2318
  while (true) {
2217
2319
  logger.debug(`[loop] Iteration with mode: ${mode}`);
@@ -2241,7 +2343,7 @@ async function loop(opts) {
2241
2343
  }
2242
2344
 
2243
2345
  var name = "happy-coder";
2244
- var version = "0.6.3";
2346
+ var version = "0.7.1-beta.1";
2245
2347
  var description = "Claude Code session sharing CLI";
2246
2348
  var author = "Kirill Dubovitskiy";
2247
2349
  var license = "MIT";
@@ -2285,15 +2387,21 @@ var files = [
2285
2387
  "package.json"
2286
2388
  ];
2287
2389
  var scripts = {
2288
- test: "vitest run",
2390
+ "why do we need to build before running tests / dev?": "We need the binary to be built so we run daemon commands which directly run the binary - we don't want them to go out of sync or have custom spawn logic depending how we started happy",
2391
+ typecheck: "tsc --noEmit",
2392
+ build: "shx rm -rf dist && npx tsc --noEmit && pkgroll",
2393
+ test: "yarn build && vitest run",
2289
2394
  "test:watch": "vitest",
2290
- build: "shx rm -rf dist && tsc --noEmit && pkgroll",
2395
+ "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",
2397
+ "dev:local-server": "yarn build && tsx --env-file .env.dev-local-server src/index.ts",
2398
+ "dev:integration-test-env": "yarn build && tsx --env-file .env.integration-test src/index.ts",
2291
2399
  prepublishOnly: "yarn build && yarn test",
2292
- typecheck: "tsc --noEmit",
2293
- dev: "yarn build && npx tsx --env-file .env.sample src/index.ts",
2294
- "dev:local-server": "yarn build && cross-env HANDY_SERVER_URL=http://localhost:3005 tsx --env-file .env.sample src/index.ts",
2295
- "version:prerelease": "npm version prerelease --preid=beta",
2296
- "publish:prerelease": "npm publish --tag beta"
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"
2297
2405
  };
2298
2406
  var dependencies = {
2299
2407
  "@anthropic-ai/claude-code": "^1.0.73",
@@ -2306,6 +2414,8 @@ var dependencies = {
2306
2414
  axios: "^1.10.0",
2307
2415
  chalk: "^5.4.1",
2308
2416
  "expo-server-sdk": "^3.15.0",
2417
+ fastify: "^5.5.0",
2418
+ "fastify-type-provider-zod": "4.0.2",
2309
2419
  "http-proxy": "^1.18.1",
2310
2420
  "http-proxy-middleware": "^3.0.5",
2311
2421
  ink: "^6.1.0",
@@ -2395,8 +2505,8 @@ function registerHandlers(session) {
2395
2505
  const { stdout, stderr } = await execAsync(data.command, options);
2396
2506
  return {
2397
2507
  success: true,
2398
- stdout: stdout || "",
2399
- stderr: stderr || "",
2508
+ stdout: stdout ? stdout.toString() : "",
2509
+ stderr: stderr ? stderr.toString() : "",
2400
2510
  exitCode: 0
2401
2511
  };
2402
2512
  } catch (error) {
@@ -2412,8 +2522,8 @@ function registerHandlers(session) {
2412
2522
  }
2413
2523
  return {
2414
2524
  success: false,
2415
- stdout: execError.stdout || "",
2416
- stderr: execError.stderr || execError.message || "Command failed",
2525
+ stdout: execError.stdout ? execError.stdout.toString() : "",
2526
+ stderr: execError.stderr ? execError.stderr.toString() : execError.message || "Command failed",
2417
2527
  exitCode: typeof execError.code === "number" ? execError.code : 1,
2418
2528
  error: execError.message || "Command failed"
2419
2529
  };
@@ -2580,8 +2690,8 @@ function registerHandlers(session) {
2580
2690
  return {
2581
2691
  success: true,
2582
2692
  exitCode: result.exitCode,
2583
- stdout: result.stdout,
2584
- stderr: result.stderr
2693
+ stdout: result.stdout.toString(),
2694
+ stderr: result.stderr.toString()
2585
2695
  };
2586
2696
  } catch (error) {
2587
2697
  logger.debug("Failed to run ripgrep:", error);
@@ -2591,6 +2701,26 @@ function registerHandlers(session) {
2591
2701
  };
2592
2702
  }
2593
2703
  });
2704
+ session.setHandler("killSession", async () => {
2705
+ logger.debug("Kill session request received");
2706
+ try {
2707
+ const response = {
2708
+ success: true,
2709
+ message: "Session termination acknowledged, exiting in 100ms"
2710
+ };
2711
+ setTimeout(() => {
2712
+ logger.debug("[KILL SESSION] Exiting process as requested");
2713
+ process.exit(0);
2714
+ }, 100);
2715
+ return response;
2716
+ } catch (error) {
2717
+ logger.debug("Failed to kill session:", error);
2718
+ return {
2719
+ success: false,
2720
+ message: error instanceof Error ? error.message : "Failed to kill session"
2721
+ };
2722
+ }
2723
+ });
2594
2724
  }
2595
2725
 
2596
2726
  const defaultSettings = {
@@ -2607,11 +2737,52 @@ async function readSettings() {
2607
2737
  return { ...defaultSettings };
2608
2738
  }
2609
2739
  }
2610
- async function writeSettings(settings) {
2611
- if (!existsSync(configuration.happyDir)) {
2612
- await mkdir(configuration.happyDir, { recursive: true });
2740
+ async function updateSettings(updater) {
2741
+ const LOCK_RETRY_INTERVAL_MS = 100;
2742
+ const MAX_LOCK_ATTEMPTS = 50;
2743
+ const STALE_LOCK_TIMEOUT_MS = 1e4;
2744
+ const lockFile = configuration.settingsFile + ".lock";
2745
+ const tmpFile = configuration.settingsFile + ".tmp";
2746
+ let fileHandle;
2747
+ let attempts = 0;
2748
+ while (attempts < MAX_LOCK_ATTEMPTS) {
2749
+ try {
2750
+ fileHandle = await open(lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
2751
+ break;
2752
+ } catch (err) {
2753
+ if (err.code === "EEXIST") {
2754
+ attempts++;
2755
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS));
2756
+ try {
2757
+ const stats = await stat$1(lockFile);
2758
+ if (Date.now() - stats.mtimeMs > STALE_LOCK_TIMEOUT_MS) {
2759
+ await unlink(lockFile).catch(() => {
2760
+ });
2761
+ }
2762
+ } catch {
2763
+ }
2764
+ } else {
2765
+ throw err;
2766
+ }
2767
+ }
2768
+ }
2769
+ if (!fileHandle) {
2770
+ throw new Error(`Failed to acquire settings lock after ${MAX_LOCK_ATTEMPTS * LOCK_RETRY_INTERVAL_MS / 1e3} seconds`);
2771
+ }
2772
+ try {
2773
+ const current = await readSettings() || { ...defaultSettings };
2774
+ const updated = await updater(current);
2775
+ if (!existsSync(configuration.happyHomeDir)) {
2776
+ await mkdir(configuration.happyHomeDir, { recursive: true });
2777
+ }
2778
+ await writeFile$1(tmpFile, JSON.stringify(updated, null, 2));
2779
+ await rename(tmpFile, configuration.settingsFile);
2780
+ return updated;
2781
+ } finally {
2782
+ await fileHandle.close();
2783
+ await unlink(lockFile).catch(() => {
2784
+ });
2613
2785
  }
2614
- await writeFile$1(configuration.settingsFile, JSON.stringify(settings, null, 2));
2615
2786
  }
2616
2787
  const credentialsSchema = z.object({
2617
2788
  secret: z.string().base64(),
@@ -2633,14 +2804,47 @@ async function readCredentials() {
2633
2804
  }
2634
2805
  }
2635
2806
  async function writeCredentials(credentials) {
2636
- if (!existsSync(configuration.happyDir)) {
2637
- await mkdir(configuration.happyDir, { recursive: true });
2807
+ if (!existsSync(configuration.happyHomeDir)) {
2808
+ await mkdir(configuration.happyHomeDir, { recursive: true });
2638
2809
  }
2639
2810
  await writeFile$1(configuration.privateKeyFile, JSON.stringify({
2640
2811
  secret: encodeBase64(credentials.secret),
2641
2812
  token: credentials.token
2642
2813
  }, null, 2));
2643
2814
  }
2815
+ async function clearCredentials() {
2816
+ if (existsSync(configuration.privateKeyFile)) {
2817
+ await unlink(configuration.privateKeyFile);
2818
+ }
2819
+ }
2820
+ async function clearMachineId() {
2821
+ await updateSettings((settings) => ({
2822
+ ...settings,
2823
+ machineId: void 0
2824
+ }));
2825
+ }
2826
+ async function readDaemonState() {
2827
+ try {
2828
+ if (!existsSync(configuration.daemonStateFile)) {
2829
+ return null;
2830
+ }
2831
+ const content = await readFile(configuration.daemonStateFile, "utf-8");
2832
+ return JSON.parse(content);
2833
+ } catch (error) {
2834
+ return null;
2835
+ }
2836
+ }
2837
+ async function writeDaemonState(state) {
2838
+ if (!existsSync(configuration.happyHomeDir)) {
2839
+ await mkdir(configuration.happyHomeDir, { recursive: true });
2840
+ }
2841
+ await writeFile$1(configuration.daemonStateFile, JSON.stringify(state, null, 2));
2842
+ }
2843
+ async function clearDaemonState() {
2844
+ if (existsSync(configuration.daemonStateFile)) {
2845
+ await unlink(configuration.daemonStateFile);
2846
+ }
2847
+ }
2644
2848
 
2645
2849
  class MessageQueue2 {
2646
2850
  constructor(modeHasher) {
@@ -2648,6 +2852,7 @@ class MessageQueue2 {
2648
2852
  logger.debug(`[MessageQueue2] Initialized`);
2649
2853
  }
2650
2854
  queue = [];
2855
+ // Made public for testing
2651
2856
  waiter = null;
2652
2857
  closed = false;
2653
2858
  onMessageHandler = null;
@@ -2669,7 +2874,8 @@ class MessageQueue2 {
2669
2874
  this.queue.push({
2670
2875
  message,
2671
2876
  mode,
2672
- modeHash
2877
+ modeHash,
2878
+ isolate: false
2673
2879
  });
2674
2880
  if (this.onMessageHandler) {
2675
2881
  this.onMessageHandler(message, mode);
@@ -2682,6 +2888,62 @@ class MessageQueue2 {
2682
2888
  }
2683
2889
  logger.debug(`[MessageQueue2] push() completed. Queue size: ${this.queue.length}`);
2684
2890
  }
2891
+ /**
2892
+ * Push a message immediately without batching delay.
2893
+ * Does not clear the queue or enforce isolation.
2894
+ */
2895
+ pushImmediate(message, mode) {
2896
+ if (this.closed) {
2897
+ throw new Error("Cannot push to closed queue");
2898
+ }
2899
+ const modeHash = this.modeHasher(mode);
2900
+ logger.debug(`[MessageQueue2] pushImmediate() called with mode hash: ${modeHash}`);
2901
+ this.queue.push({
2902
+ message,
2903
+ mode,
2904
+ modeHash,
2905
+ isolate: false
2906
+ });
2907
+ if (this.onMessageHandler) {
2908
+ this.onMessageHandler(message, mode);
2909
+ }
2910
+ if (this.waiter) {
2911
+ logger.debug(`[MessageQueue2] Notifying waiter for immediate message`);
2912
+ const waiter = this.waiter;
2913
+ this.waiter = null;
2914
+ waiter(true);
2915
+ }
2916
+ logger.debug(`[MessageQueue2] pushImmediate() completed. Queue size: ${this.queue.length}`);
2917
+ }
2918
+ /**
2919
+ * Push a message that must be processed in complete isolation.
2920
+ * Clears any pending messages and ensures this message is never batched with others.
2921
+ * Used for special commands that require dedicated processing.
2922
+ */
2923
+ pushIsolateAndClear(message, mode) {
2924
+ if (this.closed) {
2925
+ throw new Error("Cannot push to closed queue");
2926
+ }
2927
+ const modeHash = this.modeHasher(mode);
2928
+ logger.debug(`[MessageQueue2] pushIsolateAndClear() called with mode hash: ${modeHash} - clearing ${this.queue.length} pending messages`);
2929
+ this.queue = [];
2930
+ this.queue.push({
2931
+ message,
2932
+ mode,
2933
+ modeHash,
2934
+ isolate: true
2935
+ });
2936
+ if (this.onMessageHandler) {
2937
+ this.onMessageHandler(message, mode);
2938
+ }
2939
+ if (this.waiter) {
2940
+ logger.debug(`[MessageQueue2] Notifying waiter for isolated message`);
2941
+ const waiter = this.waiter;
2942
+ this.waiter = null;
2943
+ waiter(true);
2944
+ }
2945
+ logger.debug(`[MessageQueue2] pushIsolateAndClear() completed. Queue size: ${this.queue.length}`);
2946
+ }
2685
2947
  /**
2686
2948
  * Push a message to the beginning of the queue with a mode.
2687
2949
  */
@@ -2694,7 +2956,8 @@ class MessageQueue2 {
2694
2956
  this.queue.unshift({
2695
2957
  message,
2696
2958
  mode,
2697
- modeHash
2959
+ modeHash,
2960
+ isolate: false
2698
2961
  });
2699
2962
  if (this.onMessageHandler) {
2700
2963
  this.onMessageHandler(message, mode);
@@ -2758,7 +3021,7 @@ class MessageQueue2 {
2758
3021
  return this.collectBatch();
2759
3022
  }
2760
3023
  /**
2761
- * Collect a batch of messages with the same mode
3024
+ * Collect a batch of messages with the same mode, respecting isolation requirements
2762
3025
  */
2763
3026
  collectBatch() {
2764
3027
  if (this.queue.length === 0) {
@@ -2768,12 +3031,18 @@ class MessageQueue2 {
2768
3031
  const sameModeMessages = [];
2769
3032
  let mode = firstItem.mode;
2770
3033
  const targetModeHash = firstItem.modeHash;
2771
- while (this.queue.length > 0 && this.queue[0].modeHash === targetModeHash) {
3034
+ if (firstItem.isolate) {
2772
3035
  const item = this.queue.shift();
2773
3036
  sameModeMessages.push(item.message);
3037
+ logger.debug(`[MessageQueue2] Collected isolated message with mode hash: ${targetModeHash}`);
3038
+ } else {
3039
+ while (this.queue.length > 0 && this.queue[0].modeHash === targetModeHash && !this.queue[0].isolate) {
3040
+ const item = this.queue.shift();
3041
+ sameModeMessages.push(item.message);
3042
+ }
3043
+ logger.debug(`[MessageQueue2] Collected batch of ${sameModeMessages.length} messages with mode hash: ${targetModeHash}`);
2774
3044
  }
2775
3045
  const combinedMessage = sameModeMessages.join("\n");
2776
- logger.debug(`[MessageQueue2] Collected batch of ${sameModeMessages.length} messages with mode hash: ${targetModeHash}`);
2777
3046
  return {
2778
3047
  message: combinedMessage,
2779
3048
  mode
@@ -2938,8 +3207,13 @@ function startCaffeinate() {
2938
3207
  return false;
2939
3208
  }
2940
3209
  }
3210
+ let isStopping = false;
2941
3211
  function stopCaffeinate() {
3212
+ if (isStopping) {
3213
+ return;
3214
+ }
2942
3215
  if (caffeinateProcess && !caffeinateProcess.killed) {
3216
+ isStopping = true;
2943
3217
  logger.debug(`[caffeinate] Stopping caffeinate process PID ${caffeinateProcess.pid}`);
2944
3218
  try {
2945
3219
  caffeinateProcess.kill("SIGTERM");
@@ -2949,9 +3223,11 @@ function stopCaffeinate() {
2949
3223
  caffeinateProcess.kill("SIGKILL");
2950
3224
  }
2951
3225
  caffeinateProcess = null;
3226
+ isStopping = false;
2952
3227
  }, 1e3);
2953
3228
  } catch (error) {
2954
3229
  logger.debug("[caffeinate] Error stopping caffeinate:", error);
3230
+ isStopping = false;
2955
3231
  }
2956
3232
  }
2957
3233
  }
@@ -3026,99 +3302,507 @@ function extractSDKMetadataAsync(onComplete) {
3026
3302
  });
3027
3303
  }
3028
3304
 
3029
- async function start(credentials, options = {}) {
3030
- const workingDirectory = process.cwd();
3031
- const sessionTag = randomUUID();
3032
- if (options.daemonSpawn && options.startingMode === "local") {
3033
- logger.debug("Daemon spawn requested with local mode - forcing remote mode");
3034
- options.startingMode = "remote";
3035
- }
3036
- const api = new ApiClient(credentials.token, credentials.secret);
3037
- let state = {};
3038
- const settings = await readSettings() || { };
3039
- let metadata = {
3040
- path: workingDirectory,
3041
- host: os.hostname(),
3042
- version: packageJson.version,
3043
- os: os.platform(),
3044
- machineId: settings.machineId,
3045
- homeDir: os.homedir()
3046
- };
3047
- const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
3048
- logger.debug(`Session created: ${response.id}`);
3049
- extractSDKMetadataAsync(async (sdkMetadata) => {
3050
- logger.debug("[start] SDK metadata extracted, updating session:", sdkMetadata);
3051
- try {
3052
- api.session(response).updateMetadata((currentMetadata) => ({
3053
- ...currentMetadata,
3054
- tools: sdkMetadata.tools,
3055
- slashCommands: sdkMetadata.slashCommands
3056
- }));
3057
- logger.debug("[start] Session metadata updated with SDK capabilities");
3058
- } catch (error) {
3059
- logger.debug("[start] Failed to update session metadata:", error);
3305
+ async function isDaemonRunning() {
3306
+ try {
3307
+ const state = await getDaemonState();
3308
+ if (!state) {
3309
+ return false;
3060
3310
  }
3061
- });
3062
- if (options.daemonSpawn) {
3063
- console.log(`daemon:sessionIdCreated:${response.id}`);
3311
+ const isRunning = await isDaemonProcessRunning(state.pid);
3312
+ if (!isRunning) {
3313
+ logger.debug("[DAEMON RUN] Daemon PID not running, cleaning up state");
3314
+ await cleanupDaemonState();
3315
+ return false;
3316
+ }
3317
+ return true;
3318
+ } catch (error) {
3319
+ logger.debug("[DAEMON RUN] Error checking daemon status", error);
3320
+ return false;
3064
3321
  }
3065
- const session = api.session(response);
3066
- const logPath = await logger.logFilePathPromise;
3067
- logger.infoDeveloper(`Session: ${response.id}`);
3068
- logger.infoDeveloper(`Logs: ${logPath}`);
3069
- session.updateAgentState((currentState) => ({
3070
- ...currentState,
3071
- controlledByUser: options.startingMode !== "remote"
3072
- }));
3073
- const caffeinateStarted = startCaffeinate();
3074
- if (caffeinateStarted) {
3075
- logger.infoDeveloper("Sleep prevention enabled (macOS)");
3322
+ }
3323
+ async function getDaemonState() {
3324
+ try {
3325
+ return await readDaemonState();
3326
+ } catch (error) {
3327
+ logger.debug("[DAEMON RUN] Error reading daemon metadata", error);
3328
+ return null;
3076
3329
  }
3077
- const messageQueue = new MessageQueue2((mode) => hashObject(mode));
3078
- registerHandlers(session);
3079
- let currentPermissionMode = options.permissionMode;
3080
- let currentModel = options.model;
3081
- let currentFallbackModel = void 0;
3082
- let currentCustomSystemPrompt = void 0;
3083
- let currentAppendSystemPrompt = void 0;
3084
- let currentAllowedTools = void 0;
3085
- let currentDisallowedTools = void 0;
3086
- session.onUserMessage((message) => {
3087
- let messagePermissionMode = currentPermissionMode;
3088
- if (message.meta?.permissionMode) {
3089
- const validModes = ["default", "acceptEdits", "bypassPermissions", "plan"];
3090
- if (validModes.includes(message.meta.permissionMode)) {
3091
- messagePermissionMode = message.meta.permissionMode;
3092
- currentPermissionMode = messagePermissionMode;
3093
- logger.debug(`[loop] Permission mode updated from user message to: ${currentPermissionMode}`);
3330
+ }
3331
+ async function isDaemonProcessRunning(pid) {
3332
+ try {
3333
+ process.kill(pid, 0);
3334
+ return true;
3335
+ } catch {
3336
+ return false;
3337
+ }
3338
+ }
3339
+ async function cleanupDaemonState() {
3340
+ try {
3341
+ await clearDaemonState();
3342
+ logger.debug("[DAEMON RUN] Daemon state file removed");
3343
+ } catch (error) {
3344
+ logger.debug("[DAEMON RUN] Error cleaning up daemon metadata", error);
3345
+ }
3346
+ }
3347
+ function findAllHappyProcesses() {
3348
+ try {
3349
+ const output = execSync('ps aux | grep "happy.mjs" | grep -v grep', { encoding: "utf8" });
3350
+ const lines = output.trim().split("\n").filter((line) => line.trim());
3351
+ const allProcesses = [];
3352
+ for (const line of lines) {
3353
+ const parts = line.trim().split(/\s+/);
3354
+ if (parts.length < 11) continue;
3355
+ const pid = parseInt(parts[1]);
3356
+ const command = parts.slice(10).join(" ");
3357
+ let type = "unknown";
3358
+ if (pid === process.pid) {
3359
+ type = "current";
3360
+ } else if (command.includes("daemon start-sync") || command.includes("daemon start")) {
3361
+ type = "daemon";
3362
+ } else if (command.includes("--started-by daemon")) {
3363
+ type = "daemon-spawned-session";
3364
+ } else if (command.includes("doctor")) {
3365
+ type = "doctor";
3094
3366
  } else {
3095
- logger.debug(`[loop] Invalid permission mode received: ${message.meta.permissionMode}`);
3367
+ type = "user-session";
3096
3368
  }
3097
- } else {
3098
- logger.debug(`[loop] User message received with no permission mode override, using current: ${currentPermissionMode}`);
3369
+ allProcesses.push({ pid, command, type });
3099
3370
  }
3100
- let messageModel = currentModel;
3101
- if (message.meta?.hasOwnProperty("model")) {
3102
- messageModel = message.meta.model || void 0;
3103
- currentModel = messageModel;
3104
- logger.debug(`[loop] Model updated from user message: ${messageModel || "reset to default"}`);
3105
- } else {
3106
- logger.debug(`[loop] User message received with no model override, using current: ${currentModel || "default"}`);
3371
+ try {
3372
+ const devOutput = execSync('ps aux | grep -E "(tsx.*src/index.ts|yarn.*tsx)" | grep -v grep', { encoding: "utf8" });
3373
+ const devLines = devOutput.trim().split("\n").filter((line) => line.trim());
3374
+ for (const line of devLines) {
3375
+ const parts = line.trim().split(/\s+/);
3376
+ if (parts.length < 11) continue;
3377
+ const pid = parseInt(parts[1]);
3378
+ const command = parts.slice(10).join(" ");
3379
+ let workingDir = "";
3380
+ try {
3381
+ const pwdOutput = execSync(`pwdx ${pid} 2>/dev/null`, { encoding: "utf8" });
3382
+ workingDir = pwdOutput.replace(`${pid}:`, "").trim();
3383
+ } catch {
3384
+ }
3385
+ if (workingDir.includes("happy-cli")) {
3386
+ allProcesses.push({ pid, command, type: "dev-session" });
3387
+ }
3388
+ }
3389
+ } catch {
3107
3390
  }
3108
- let messageCustomSystemPrompt = currentCustomSystemPrompt;
3109
- if (message.meta?.hasOwnProperty("customSystemPrompt")) {
3110
- messageCustomSystemPrompt = message.meta.customSystemPrompt || void 0;
3111
- currentCustomSystemPrompt = messageCustomSystemPrompt;
3112
- logger.debug(`[loop] Custom system prompt updated from user message: ${messageCustomSystemPrompt ? "set" : "reset to none"}`);
3113
- } else {
3114
- logger.debug(`[loop] User message received with no custom system prompt override, using current: ${currentCustomSystemPrompt ? "set" : "none"}`);
3391
+ return allProcesses;
3392
+ } catch (error) {
3393
+ return [];
3394
+ }
3395
+ }
3396
+ function findRunawayHappyProcesses() {
3397
+ try {
3398
+ const output = execSync('ps aux | grep "happy.mjs" | grep -v grep', { encoding: "utf8" });
3399
+ const lines = output.trim().split("\n").filter((line) => line.trim());
3400
+ const processes = [];
3401
+ for (const line of lines) {
3402
+ const parts = line.trim().split(/\s+/);
3403
+ if (parts.length < 11) continue;
3404
+ const pid = parseInt(parts[1]);
3405
+ const command = parts.slice(10).join(" ");
3406
+ if (pid === process.pid) continue;
3407
+ if (command.includes("--started-by daemon") || command.includes("daemon start-sync") || command.includes("daemon start")) {
3408
+ processes.push({ pid, command });
3409
+ }
3115
3410
  }
3116
- let messageFallbackModel = currentFallbackModel;
3117
- if (message.meta?.hasOwnProperty("fallbackModel")) {
3118
- messageFallbackModel = message.meta.fallbackModel || void 0;
3119
- currentFallbackModel = messageFallbackModel;
3120
- logger.debug(`[loop] Fallback model updated from user message: ${messageFallbackModel || "reset to none"}`);
3121
- } else {
3411
+ return processes;
3412
+ } catch (error) {
3413
+ return [];
3414
+ }
3415
+ }
3416
+ async function killRunawayHappyProcesses() {
3417
+ const runawayProcesses = findRunawayHappyProcesses();
3418
+ const errors = [];
3419
+ let killed = 0;
3420
+ for (const { pid, command } of runawayProcesses) {
3421
+ try {
3422
+ process.kill(pid, "SIGTERM");
3423
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
3424
+ try {
3425
+ process.kill(pid, 0);
3426
+ console.log(`Process PID ${pid} ignored SIGTERM, using SIGKILL`);
3427
+ process.kill(pid, "SIGKILL");
3428
+ } catch {
3429
+ }
3430
+ killed++;
3431
+ console.log(`Killed runaway process PID ${pid}: ${command}`);
3432
+ } catch (error) {
3433
+ errors.push({ pid, error: error.message });
3434
+ }
3435
+ }
3436
+ return { killed, errors };
3437
+ }
3438
+ async function stopDaemon() {
3439
+ try {
3440
+ stopCaffeinate();
3441
+ logger.debug("Stopped sleep prevention");
3442
+ const state = await getDaemonState();
3443
+ if (!state) {
3444
+ logger.debug("No daemon state found");
3445
+ return;
3446
+ }
3447
+ logger.debug(`Stopping daemon with PID ${state.pid}`);
3448
+ try {
3449
+ const { stopDaemonHttp } = await Promise.resolve().then(function () { return controlClient; });
3450
+ await stopDaemonHttp();
3451
+ await waitForProcessDeath(state.pid, 5e3);
3452
+ logger.debug("Daemon stopped gracefully via HTTP");
3453
+ return;
3454
+ } catch (error) {
3455
+ logger.debug("HTTP stop failed, will force kill", error);
3456
+ }
3457
+ try {
3458
+ process.kill(state.pid, "SIGKILL");
3459
+ logger.debug("Force killed daemon");
3460
+ } catch (error) {
3461
+ logger.debug("Daemon already dead");
3462
+ }
3463
+ } catch (error) {
3464
+ logger.debug("Error stopping daemon", error);
3465
+ }
3466
+ }
3467
+ async function waitForProcessDeath(pid, timeout) {
3468
+ const start = Date.now();
3469
+ while (Date.now() - start < timeout) {
3470
+ try {
3471
+ process.kill(pid, 0);
3472
+ await new Promise((resolve) => setTimeout(resolve, 100));
3473
+ } catch {
3474
+ return;
3475
+ }
3476
+ }
3477
+ throw new Error("Process did not die within timeout");
3478
+ }
3479
+
3480
+ var utils = /*#__PURE__*/Object.freeze({
3481
+ __proto__: null,
3482
+ cleanupDaemonState: cleanupDaemonState,
3483
+ findAllHappyProcesses: findAllHappyProcesses,
3484
+ findRunawayHappyProcesses: findRunawayHappyProcesses,
3485
+ getDaemonState: getDaemonState,
3486
+ isDaemonRunning: isDaemonRunning,
3487
+ killRunawayHappyProcesses: killRunawayHappyProcesses,
3488
+ stopDaemon: stopDaemon
3489
+ });
3490
+
3491
+ function getEnvironmentInfo() {
3492
+ return {
3493
+ PWD: process.env.PWD,
3494
+ HAPPY_HOME_DIR: process.env.HAPPY_HOME_DIR,
3495
+ HAPPY_SERVER_URL: process.env.HAPPY_SERVER_URL,
3496
+ HAPPY_PROJECT_ROOT: process.env.HAPPY_PROJECT_ROOT,
3497
+ DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING: process.env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING,
3498
+ NODE_ENV: process.env.NODE_ENV,
3499
+ DEBUG: process.env.DEBUG,
3500
+ workingDirectory: process.cwd(),
3501
+ processArgv: process.argv,
3502
+ happyDir: configuration?.happyHomeDir,
3503
+ serverUrl: configuration?.serverUrl,
3504
+ logsDir: configuration?.logsDir
3505
+ };
3506
+ }
3507
+ function getLogFiles(logDir) {
3508
+ if (!existsSync(logDir)) {
3509
+ return [];
3510
+ }
3511
+ try {
3512
+ return readdirSync(logDir).filter((file) => file.endsWith(".log")).map((file) => {
3513
+ const path = join(logDir, file);
3514
+ const stats = statSync(path);
3515
+ return { file, path, modified: stats.mtime };
3516
+ }).sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 10);
3517
+ } catch {
3518
+ return [];
3519
+ }
3520
+ }
3521
+ async function runDoctorCommand() {
3522
+ console.log(chalk.bold.cyan("\n\u{1FA7A} Happy CLI Doctor\n"));
3523
+ console.log(chalk.bold("\u{1F4CB} Basic Information"));
3524
+ console.log(`Happy CLI Version: ${chalk.green(packageJson.version)}`);
3525
+ console.log(`Platform: ${chalk.green(process.platform)} ${process.arch}`);
3526
+ console.log(`Node.js Version: ${chalk.green(process.version)}`);
3527
+ console.log("");
3528
+ console.log(chalk.bold("\u2699\uFE0F Configuration"));
3529
+ console.log(`Happy Home: ${chalk.blue(configuration.happyHomeDir)}`);
3530
+ console.log(`Server URL: ${chalk.blue(configuration.serverUrl)}`);
3531
+ console.log(`Logs Dir: ${chalk.blue(configuration.logsDir)}`);
3532
+ console.log(chalk.bold("\n\u{1F30D} Environment Variables"));
3533
+ const env = getEnvironmentInfo();
3534
+ console.log(`HAPPY_HOME_DIR: ${env.HAPPY_HOME_DIR ? chalk.green(env.HAPPY_HOME_DIR) : chalk.gray("not set")}`);
3535
+ console.log(`HAPPY_SERVER_URL: ${env.HAPPY_SERVER_URL ? chalk.green(env.HAPPY_SERVER_URL) : chalk.gray("not set")}`);
3536
+ console.log(`DANGEROUSLY_LOG_TO_SERVER: ${env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING ? chalk.yellow("ENABLED") : chalk.gray("not set")}`);
3537
+ console.log(`DEBUG: ${env.DEBUG ? chalk.green(env.DEBUG) : chalk.gray("not set")}`);
3538
+ console.log(`NODE_ENV: ${env.NODE_ENV ? chalk.green(env.NODE_ENV) : chalk.gray("not set")}`);
3539
+ try {
3540
+ const settings = await readSettings();
3541
+ console.log(chalk.bold("\n\u{1F4C4} Settings (settings.json):"));
3542
+ console.log(chalk.gray(JSON.stringify(settings, null, 2)));
3543
+ } catch (error) {
3544
+ console.log(chalk.bold("\n\u{1F4C4} Settings:"));
3545
+ console.log(chalk.red("\u274C Failed to read settings"));
3546
+ }
3547
+ console.log(chalk.bold("\n\u{1F510} Authentication"));
3548
+ try {
3549
+ const credentials = await readCredentials();
3550
+ if (credentials) {
3551
+ console.log(chalk.green("\u2713 Authenticated (credentials found)"));
3552
+ } else {
3553
+ console.log(chalk.yellow("\u26A0\uFE0F Not authenticated (no credentials)"));
3554
+ }
3555
+ } catch (error) {
3556
+ console.log(chalk.red("\u274C Error reading credentials"));
3557
+ }
3558
+ console.log(chalk.bold("\n\u{1F916} Daemon Status"));
3559
+ try {
3560
+ const isRunning = await isDaemonRunning();
3561
+ const state = await getDaemonState();
3562
+ if (isRunning && state) {
3563
+ console.log(chalk.green("\u2713 Daemon is running"));
3564
+ console.log(` PID: ${state.pid}`);
3565
+ console.log(` Started: ${new Date(state.startTime).toLocaleString()}`);
3566
+ console.log(` CLI Version: ${state.startedWithCliVersion}`);
3567
+ if (state.httpPort) {
3568
+ console.log(` HTTP Port: ${state.httpPort}`);
3569
+ }
3570
+ } else if (state && !isRunning) {
3571
+ console.log(chalk.yellow("\u26A0\uFE0F Daemon state exists but process not running (stale)"));
3572
+ } else {
3573
+ console.log(chalk.red("\u274C Daemon is not running"));
3574
+ }
3575
+ if (state) {
3576
+ console.log(chalk.bold("\n\u{1F4C4} Daemon State:"));
3577
+ console.log(chalk.blue(`Location: ${configuration.daemonStateFile}`));
3578
+ console.log(chalk.gray(JSON.stringify(state, null, 2)));
3579
+ }
3580
+ const allProcesses = findAllHappyProcesses();
3581
+ if (allProcesses.length > 0) {
3582
+ console.log(chalk.bold("\n\u{1F50D} All Happy CLI Processes"));
3583
+ const grouped = allProcesses.reduce((groups, process2) => {
3584
+ if (!groups[process2.type]) groups[process2.type] = [];
3585
+ groups[process2.type].push(process2);
3586
+ return groups;
3587
+ }, {});
3588
+ Object.entries(grouped).forEach(([type, processes]) => {
3589
+ const typeLabels = {
3590
+ "current": "\u{1F4CD} Current Process",
3591
+ "daemon": "\u{1F916} Daemon",
3592
+ "daemon-spawned-session": "\u{1F517} Daemon-Spawned Sessions",
3593
+ "user-session": "\u{1F464} User Sessions",
3594
+ "dev-daemon": "\u{1F6E0}\uFE0F Dev Daemon",
3595
+ "dev-session": "\u{1F6E0}\uFE0F Dev Sessions",
3596
+ "dev-doctor": "\u{1F6E0}\uFE0F Dev Doctor",
3597
+ "dev-related": "\u{1F6E0}\uFE0F Dev Related",
3598
+ "doctor": "\u{1FA7A} Doctor",
3599
+ "unknown": "\u2753 Unknown"
3600
+ };
3601
+ console.log(chalk.blue(`
3602
+ ${typeLabels[type] || type}:`));
3603
+ processes.forEach(({ pid, command }) => {
3604
+ const color = type === "current" ? chalk.green : type.startsWith("dev") ? chalk.cyan : type.includes("daemon") ? chalk.blue : chalk.gray;
3605
+ console.log(` ${color(`PID ${pid}`)}: ${chalk.gray(command)}`);
3606
+ });
3607
+ });
3608
+ }
3609
+ const runawayProcesses = findRunawayHappyProcesses();
3610
+ if (runawayProcesses.length > 0) {
3611
+ console.log(chalk.bold("\n\u{1F6A8} Runaway Happy processes detected"));
3612
+ console.log(chalk.gray("These processes were left running after daemon crashes."));
3613
+ runawayProcesses.forEach(({ pid, command }) => {
3614
+ console.log(` ${chalk.yellow(`PID ${pid}`)}: ${chalk.gray(command)}`);
3615
+ });
3616
+ console.log(chalk.blue("\nTo clean up: happy daemon kill-runaway"));
3617
+ }
3618
+ if (allProcesses.length > 1) {
3619
+ console.log(chalk.bold("\n\u{1F4A1} Process Management"));
3620
+ console.log(chalk.gray("To kill runaway processes: happy daemon kill-runaway"));
3621
+ }
3622
+ } catch (error) {
3623
+ console.log(chalk.red("\u274C Error checking daemon status"));
3624
+ }
3625
+ console.log(chalk.bold("\n\u{1F4DD} Log Files"));
3626
+ const mainLogs = getLogFiles(configuration.logsDir);
3627
+ if (mainLogs.length > 0) {
3628
+ console.log(chalk.blue("\nMain Logs:"));
3629
+ mainLogs.forEach(({ file, path, modified }) => {
3630
+ console.log(` ${chalk.green(file)} - ${modified.toLocaleString()}`);
3631
+ console.log(chalk.gray(` ${path}`));
3632
+ });
3633
+ } else {
3634
+ console.log(chalk.yellow("No main log files found"));
3635
+ }
3636
+ const daemonLogs = mainLogs.filter(({ file }) => file.includes("daemon"));
3637
+ if (daemonLogs.length > 0) {
3638
+ console.log(chalk.blue("\nDaemon Logs:"));
3639
+ daemonLogs.forEach(({ file, path, modified }) => {
3640
+ console.log(` ${chalk.green(file)} - ${modified.toLocaleString()}`);
3641
+ console.log(chalk.gray(` ${path}`));
3642
+ });
3643
+ } else {
3644
+ console.log(chalk.yellow("No daemon log files found"));
3645
+ }
3646
+ console.log(chalk.bold("\n\u{1F41B} Support & Bug Reports"));
3647
+ console.log(`Report issues: ${chalk.blue("https://github.com/slopus/happy-cli/issues")}`);
3648
+ console.log(`Documentation: ${chalk.blue("https://happy.engineering/")}`);
3649
+ console.log(chalk.green("\n\u2705 Doctor diagnosis complete!\n"));
3650
+ }
3651
+
3652
+ async function daemonPost(path, body) {
3653
+ const state = await getDaemonState();
3654
+ if (!state?.httpPort) {
3655
+ throw new Error("No daemon running");
3656
+ }
3657
+ try {
3658
+ const response = await fetch(`http://127.0.0.1:${state.httpPort}${path}`, {
3659
+ method: "POST",
3660
+ headers: { "Content-Type": "application/json" },
3661
+ body: JSON.stringify(body || {}),
3662
+ signal: AbortSignal.timeout(5e3)
3663
+ });
3664
+ if (!response.ok) {
3665
+ throw new Error(`HTTP ${response.status}`);
3666
+ }
3667
+ return await response.json();
3668
+ } catch (error) {
3669
+ logger.debug(`[CONTROL CLIENT] Request failed: ${path}`, error);
3670
+ throw error;
3671
+ }
3672
+ }
3673
+ async function notifyDaemonSessionStarted(sessionId, metadata) {
3674
+ await daemonPost("/session-started", {
3675
+ sessionId,
3676
+ metadata
3677
+ });
3678
+ }
3679
+ async function listDaemonSessions() {
3680
+ const result = await daemonPost("/list");
3681
+ return result.children || [];
3682
+ }
3683
+ async function stopDaemonSession(sessionId) {
3684
+ const result = await daemonPost("/stop-session", { sessionId });
3685
+ return result.success || false;
3686
+ }
3687
+ async function stopDaemonHttp() {
3688
+ await daemonPost("/stop");
3689
+ }
3690
+
3691
+ var controlClient = /*#__PURE__*/Object.freeze({
3692
+ __proto__: null,
3693
+ listDaemonSessions: listDaemonSessions,
3694
+ notifyDaemonSessionStarted: notifyDaemonSessionStarted,
3695
+ stopDaemonHttp: stopDaemonHttp,
3696
+ stopDaemonSession: stopDaemonSession
3697
+ });
3698
+
3699
+ async function start(credentials, options = {}) {
3700
+ const workingDirectory = process.cwd();
3701
+ const sessionTag = randomUUID();
3702
+ logger.debugLargeJson("[START] Happy process started", getEnvironmentInfo());
3703
+ logger.debug(`[START] Options: startedBy=${options.startedBy}, startingMode=${options.startingMode}`);
3704
+ if (options.startedBy === "daemon" && options.startingMode === "local") {
3705
+ logger.debug("Daemon spawn requested with local mode - forcing remote mode");
3706
+ options.startingMode = "remote";
3707
+ }
3708
+ const api = new ApiClient(credentials.token, credentials.secret);
3709
+ let state = {};
3710
+ const settings = await readSettings();
3711
+ const machineId = settings?.machineId || "unknown";
3712
+ logger.debug(`Using machineId: ${machineId}`);
3713
+ let metadata = {
3714
+ path: workingDirectory,
3715
+ host: os.hostname(),
3716
+ version: packageJson.version,
3717
+ os: os.platform(),
3718
+ machineId,
3719
+ homeDir: os.homedir(),
3720
+ happyHomeDir: configuration.happyHomeDir,
3721
+ startedFromDaemon: options.startedBy === "daemon",
3722
+ hostPid: process.pid,
3723
+ startedBy: options.startedBy || "terminal"
3724
+ };
3725
+ const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
3726
+ logger.debug(`Session created: ${response.id}`);
3727
+ try {
3728
+ const daemonState = await getDaemonState();
3729
+ if (daemonState?.httpPort) {
3730
+ await notifyDaemonSessionStarted(response.id, metadata);
3731
+ logger.debug(`[START] Reported session ${response.id} to daemon`);
3732
+ }
3733
+ } catch (error) {
3734
+ logger.debug("[START] Failed to report to daemon (may not be running):", error);
3735
+ }
3736
+ extractSDKMetadataAsync(async (sdkMetadata) => {
3737
+ logger.debug("[start] SDK metadata extracted, updating session:", sdkMetadata);
3738
+ try {
3739
+ api.sessionSyncClient(response).updateMetadata((currentMetadata) => ({
3740
+ ...currentMetadata,
3741
+ tools: sdkMetadata.tools,
3742
+ slashCommands: sdkMetadata.slashCommands
3743
+ }));
3744
+ logger.debug("[start] Session metadata updated with SDK capabilities");
3745
+ } catch (error) {
3746
+ logger.debug("[start] Failed to update session metadata:", error);
3747
+ }
3748
+ });
3749
+ const session = api.sessionSyncClient(response);
3750
+ const logPath = await logger.logFilePathPromise;
3751
+ logger.infoDeveloper(`Session: ${response.id}`);
3752
+ logger.infoDeveloper(`Logs: ${logPath}`);
3753
+ session.updateAgentState((currentState) => ({
3754
+ ...currentState,
3755
+ controlledByUser: options.startingMode !== "remote"
3756
+ }));
3757
+ const caffeinateStarted = startCaffeinate();
3758
+ if (caffeinateStarted) {
3759
+ logger.infoDeveloper("Sleep prevention enabled (macOS)");
3760
+ }
3761
+ const messageQueue = new MessageQueue2((mode) => hashObject(mode));
3762
+ registerHandlers(session);
3763
+ let currentPermissionMode = options.permissionMode;
3764
+ let currentModel = options.model;
3765
+ let currentFallbackModel = void 0;
3766
+ let currentCustomSystemPrompt = void 0;
3767
+ let currentAppendSystemPrompt = void 0;
3768
+ let currentAllowedTools = void 0;
3769
+ let currentDisallowedTools = void 0;
3770
+ session.onUserMessage((message) => {
3771
+ let messagePermissionMode = currentPermissionMode;
3772
+ if (message.meta?.permissionMode) {
3773
+ const validModes = ["default", "acceptEdits", "bypassPermissions", "plan"];
3774
+ if (validModes.includes(message.meta.permissionMode)) {
3775
+ messagePermissionMode = message.meta.permissionMode;
3776
+ currentPermissionMode = messagePermissionMode;
3777
+ logger.debug(`[loop] Permission mode updated from user message to: ${currentPermissionMode}`);
3778
+ } else {
3779
+ logger.debug(`[loop] Invalid permission mode received: ${message.meta.permissionMode}`);
3780
+ }
3781
+ } else {
3782
+ logger.debug(`[loop] User message received with no permission mode override, using current: ${currentPermissionMode}`);
3783
+ }
3784
+ let messageModel = currentModel;
3785
+ if (message.meta?.hasOwnProperty("model")) {
3786
+ messageModel = message.meta.model || void 0;
3787
+ currentModel = messageModel;
3788
+ logger.debug(`[loop] Model updated from user message: ${messageModel || "reset to default"}`);
3789
+ } else {
3790
+ logger.debug(`[loop] User message received with no model override, using current: ${currentModel || "default"}`);
3791
+ }
3792
+ let messageCustomSystemPrompt = currentCustomSystemPrompt;
3793
+ if (message.meta?.hasOwnProperty("customSystemPrompt")) {
3794
+ messageCustomSystemPrompt = message.meta.customSystemPrompt || void 0;
3795
+ currentCustomSystemPrompt = messageCustomSystemPrompt;
3796
+ logger.debug(`[loop] Custom system prompt updated from user message: ${messageCustomSystemPrompt ? "set" : "reset to none"}`);
3797
+ } else {
3798
+ logger.debug(`[loop] User message received with no custom system prompt override, using current: ${currentCustomSystemPrompt ? "set" : "none"}`);
3799
+ }
3800
+ let messageFallbackModel = currentFallbackModel;
3801
+ if (message.meta?.hasOwnProperty("fallbackModel")) {
3802
+ messageFallbackModel = message.meta.fallbackModel || void 0;
3803
+ currentFallbackModel = messageFallbackModel;
3804
+ logger.debug(`[loop] Fallback model updated from user message: ${messageFallbackModel || "reset to none"}`);
3805
+ } else {
3122
3806
  logger.debug(`[loop] User message received with no fallback model override, using current: ${currentFallbackModel || "none"}`);
3123
3807
  }
3124
3808
  let messageAppendSystemPrompt = currentAppendSystemPrompt;
@@ -3145,6 +3829,37 @@ async function start(credentials, options = {}) {
3145
3829
  } else {
3146
3830
  logger.debug(`[loop] User message received with no disallowed tools override, using current: ${currentDisallowedTools ? currentDisallowedTools.join(", ") : "none"}`);
3147
3831
  }
3832
+ const specialCommand = parseSpecialCommand(message.content.text);
3833
+ if (specialCommand.type === "compact") {
3834
+ logger.debug("[start] Detected /compact command");
3835
+ const enhancedMode2 = {
3836
+ permissionMode: messagePermissionMode || "default",
3837
+ model: messageModel,
3838
+ fallbackModel: messageFallbackModel,
3839
+ customSystemPrompt: messageCustomSystemPrompt,
3840
+ appendSystemPrompt: messageAppendSystemPrompt,
3841
+ allowedTools: messageAllowedTools,
3842
+ disallowedTools: messageDisallowedTools
3843
+ };
3844
+ messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode2);
3845
+ logger.debugLargeJson("[start] /compact command pushed to queue:", message);
3846
+ return;
3847
+ }
3848
+ if (specialCommand.type === "clear") {
3849
+ logger.debug("[start] Detected /clear command");
3850
+ const enhancedMode2 = {
3851
+ permissionMode: messagePermissionMode || "default",
3852
+ model: messageModel,
3853
+ fallbackModel: messageFallbackModel,
3854
+ customSystemPrompt: messageCustomSystemPrompt,
3855
+ appendSystemPrompt: messageAppendSystemPrompt,
3856
+ allowedTools: messageAllowedTools,
3857
+ disallowedTools: messageDisallowedTools
3858
+ };
3859
+ messageQueue.pushIsolateAndClear(specialCommand.originalMessage || message.content.text, enhancedMode2);
3860
+ logger.debugLargeJson("[start] /compact command pushed to queue:", message);
3861
+ return;
3862
+ }
3148
3863
  const enhancedMode = {
3149
3864
  permissionMode: messagePermissionMode || "default",
3150
3865
  model: messageModel,
@@ -3157,6 +3872,32 @@ async function start(credentials, options = {}) {
3157
3872
  messageQueue.push(message.content.text, enhancedMode);
3158
3873
  logger.debugLargeJson("User message pushed to queue:", message);
3159
3874
  });
3875
+ const cleanup = async () => {
3876
+ logger.debug("[START] Received termination signal, cleaning up...");
3877
+ try {
3878
+ if (session) {
3879
+ session.sendSessionDeath();
3880
+ await session.flush();
3881
+ await session.close();
3882
+ }
3883
+ stopCaffeinate();
3884
+ logger.debug("[START] Cleanup complete, exiting");
3885
+ process.exit(0);
3886
+ } catch (error) {
3887
+ logger.debug("[START] Error during cleanup:", error);
3888
+ process.exit(1);
3889
+ }
3890
+ };
3891
+ process.on("SIGTERM", cleanup);
3892
+ process.on("SIGINT", cleanup);
3893
+ process.on("uncaughtException", (error) => {
3894
+ logger.debug("[START] Uncaught exception:", error);
3895
+ cleanup();
3896
+ });
3897
+ process.on("unhandledRejection", (reason) => {
3898
+ logger.debug("[START] Unhandled rejection:", reason);
3899
+ cleanup();
3900
+ });
3160
3901
  await loop({
3161
3902
  path: workingDirectory,
3162
3903
  model: options.model,
@@ -3171,6 +3912,8 @@ async function start(credentials, options = {}) {
3171
3912
  controlledByUser: newMode === "local"
3172
3913
  }));
3173
3914
  },
3915
+ onSessionReady: (sessionInstance) => {
3916
+ },
3174
3917
  mcpServers: {},
3175
3918
  session,
3176
3919
  claudeEnvVars: options.claudeEnvVars,
@@ -3210,7 +3953,7 @@ async function openBrowser(url) {
3210
3953
  return false;
3211
3954
  }
3212
3955
  logger.debug(`[browser] Attempting to open URL: ${url}`);
3213
- await open(url);
3956
+ await open$1(url);
3214
3957
  logger.debug("[browser] Browser opened successfully");
3215
3958
  return true;
3216
3959
  } catch (error) {
@@ -3264,10 +4007,14 @@ async function doAuth() {
3264
4007
  const secret = new Uint8Array(randomBytes(32));
3265
4008
  const keypair = tweetnacl.box.keyPair.fromSecretKey(secret);
3266
4009
  try {
4010
+ console.log(`[AUTH DEBUG] Sending auth request to: ${configuration.serverUrl}/v1/auth/request`);
4011
+ console.log(`[AUTH DEBUG] Public key: ${encodeBase64(keypair.publicKey).substring(0, 20)}...`);
3267
4012
  await axios.post(`${configuration.serverUrl}/v1/auth/request`, {
3268
4013
  publicKey: encodeBase64(keypair.publicKey)
3269
4014
  });
4015
+ console.log(`[AUTH DEBUG] Auth request sent successfully`);
3270
4016
  } catch (error) {
4017
+ console.log(`[AUTH DEBUG] Failed to send auth request:`, error);
3271
4018
  console.log("Failed to create authentication request, please try again later.");
3272
4019
  return null;
3273
4020
  }
@@ -3384,550 +4131,375 @@ function decryptWithEphemeralKey(encryptedBundle, recipientSecretKey) {
3384
4131
  }
3385
4132
  return decrypted;
3386
4133
  }
4134
+ async function authAndSetupMachineIfNeeded() {
4135
+ logger.debug("[AUTH] Starting auth and machine setup...");
4136
+ let credentials = await readCredentials();
4137
+ if (!credentials) {
4138
+ logger.debug("[AUTH] No credentials found, starting authentication flow...");
4139
+ const authResult = await doAuth();
4140
+ if (!authResult) {
4141
+ throw new Error("Authentication failed or was cancelled");
4142
+ }
4143
+ credentials = authResult;
4144
+ } else {
4145
+ logger.debug("[AUTH] Using existing credentials");
4146
+ }
4147
+ const settings = await updateSettings(async (s) => {
4148
+ if (!s.machineId) {
4149
+ return {
4150
+ ...s,
4151
+ machineId: randomUUID()
4152
+ };
4153
+ }
4154
+ return s;
4155
+ });
4156
+ logger.debug(`[AUTH] Machine ID: ${settings.machineId}`);
4157
+ return { credentials, machineId: settings.machineId };
4158
+ }
3387
4159
 
3388
- class ApiDaemonSession extends EventEmitter {
3389
- socket;
3390
- machineIdentity;
3391
- keepAliveInterval = null;
3392
- token;
3393
- secret;
3394
- spawnedProcesses = /* @__PURE__ */ new Set();
3395
- machineRegistered = false;
3396
- constructor(token, secret, machineIdentity) {
3397
- super();
3398
- this.token = token;
3399
- this.secret = secret;
3400
- this.machineIdentity = machineIdentity;
3401
- logger.debug(`[DAEMON SESSION] Connecting to server: ${configuration.serverUrl}`);
3402
- const socket = io(configuration.serverUrl, {
3403
- auth: {
3404
- token: this.token,
3405
- clientType: "machine-scoped",
3406
- machineId: this.machineIdentity.machineId
3407
- },
3408
- path: "/v1/updates",
3409
- reconnection: true,
3410
- reconnectionAttempts: Infinity,
3411
- reconnectionDelay: 1e3,
3412
- reconnectionDelayMax: 5e3,
3413
- transports: ["websocket"],
3414
- withCredentials: true,
3415
- autoConnect: false
3416
- });
3417
- socket.on("connect", async () => {
3418
- logger.debug("[DAEMON SESSION] Socket connected");
3419
- logger.debug(`[DAEMON SESSION] Connected with auth - token: ${this.token.substring(0, 10)}..., machineId: ${this.machineIdentity.machineId}`);
3420
- if (!this.machineRegistered) {
3421
- await this.registerMachine();
3422
- }
3423
- const rpcMethod = `${this.machineIdentity.machineId}:spawn-happy-session`;
3424
- socket.emit("rpc-register", { method: rpcMethod });
3425
- logger.debug(`[DAEMON SESSION] Emitted RPC registration: ${rpcMethod}`);
3426
- this.emit("connected");
3427
- this.startKeepAlive();
4160
+ function startDaemonControlServer({
4161
+ getChildren,
4162
+ stopSession,
4163
+ spawnSession,
4164
+ requestShutdown,
4165
+ onHappySessionWebhook
4166
+ }) {
4167
+ return new Promise((resolve) => {
4168
+ const app = fastify({
4169
+ logger: false
4170
+ // We use our own logger
3428
4171
  });
3429
- socket.on("rpc-request", async (data, callback) => {
3430
- logger.debug(`[DAEMON SESSION] Received RPC request: ${JSON.stringify(data)}`);
3431
- const expectedMethod = `${this.machineIdentity.machineId}:spawn-happy-session`;
3432
- if (data.method === expectedMethod) {
3433
- logger.debug("[DAEMON SESSION] Processing spawn-happy-session RPC");
3434
- try {
3435
- const { directory } = data.params || {};
3436
- if (!directory) {
3437
- throw new Error("Directory is required");
3438
- }
3439
- const args = [
3440
- "--daemon-spawn",
3441
- "--happy-starting-mode",
3442
- "remote"
3443
- // ALWAYS force remote mode for daemon spawns
3444
- ];
3445
- if (configuration.installationLocation === "local") {
3446
- args.push("--local");
3447
- }
3448
- logger.debug(`[DAEMON SESSION] Spawning happy in directory: ${directory} with args: ${args.join(" ")}`);
3449
- const happyBinPath = join$1(projectPath(), "bin", "happy.mjs");
3450
- logger.debug(`[DAEMON SESSION] Using happy binary at: ${happyBinPath}`);
3451
- const executable = happyBinPath;
3452
- const spawnArgs = args;
3453
- logger.debug(`[DAEMON SESSION] Spawn: executable=${executable}, args=${JSON.stringify(spawnArgs)}, cwd=${directory}`);
3454
- const happyProcess = spawn$1(executable, spawnArgs, {
3455
- cwd: directory,
3456
- detached: true,
3457
- stdio: ["ignore", "pipe", "pipe"]
3458
- // We need stdout
3459
- });
3460
- this.spawnedProcesses.add(happyProcess);
3461
- this.updateChildPidsInMetadata();
3462
- let sessionId = null;
3463
- let output = "";
3464
- let timeoutId = null;
3465
- const cleanup = () => {
3466
- happyProcess.stdout.removeAllListeners("data");
3467
- happyProcess.stderr.removeAllListeners("data");
3468
- happyProcess.removeAllListeners("error");
3469
- happyProcess.removeAllListeners("exit");
3470
- if (timeoutId) {
3471
- clearTimeout(timeoutId);
3472
- timeoutId = null;
3473
- }
3474
- };
3475
- happyProcess.stdout.on("data", (data2) => {
3476
- output += data2.toString();
3477
- const match = output.match(/daemon:sessionIdCreated:(.+?)[\n\r]/);
3478
- if (match && !sessionId) {
3479
- sessionId = match[1];
3480
- logger.debug(`[DAEMON SESSION] Session spawned successfully: ${sessionId}`);
3481
- callback({ sessionId });
3482
- cleanup();
3483
- happyProcess.unref();
3484
- }
3485
- });
3486
- happyProcess.stderr.on("data", (data2) => {
3487
- logger.debug(`[DAEMON SESSION] Spawned process stderr: ${data2.toString()}`);
3488
- });
3489
- happyProcess.on("error", (error) => {
3490
- logger.debug("[DAEMON SESSION] Error spawning session:", error);
3491
- if (!sessionId) {
3492
- callback({ error: `Failed to spawn: ${error.message}` });
3493
- cleanup();
3494
- this.spawnedProcesses.delete(happyProcess);
3495
- }
3496
- });
3497
- happyProcess.on("exit", (code, signal) => {
3498
- logger.debug(`[DAEMON SESSION] Spawned process exited with code ${code}, signal ${signal}`);
3499
- this.spawnedProcesses.delete(happyProcess);
3500
- this.updateChildPidsInMetadata();
3501
- if (!sessionId) {
3502
- callback({ error: `Process exited before session ID received` });
3503
- cleanup();
3504
- }
3505
- });
3506
- timeoutId = setTimeout(() => {
3507
- if (!sessionId) {
3508
- logger.debug("[DAEMON SESSION] Timeout waiting for session ID");
3509
- callback({ error: "Timeout waiting for session" });
3510
- cleanup();
3511
- happyProcess.kill();
3512
- this.spawnedProcesses.delete(happyProcess);
3513
- this.updateChildPidsInMetadata();
3514
- }
3515
- }, 1e4);
3516
- } catch (error) {
3517
- logger.debug("[DAEMON SESSION] Error spawning session:", error);
3518
- callback({ error: error instanceof Error ? error.message : "Unknown error" });
3519
- }
3520
- } else {
3521
- logger.debug(`[DAEMON SESSION] Unknown RPC method: ${data.method}`);
3522
- callback({ error: `Unknown method: ${data.method}` });
4172
+ app.setValidatorCompiler(validatorCompiler);
4173
+ app.setSerializerCompiler(serializerCompiler);
4174
+ const typed = app.withTypeProvider();
4175
+ typed.post("/session-started", {
4176
+ schema: {
4177
+ body: z$1.object({
4178
+ sessionId: z$1.string(),
4179
+ metadata: z$1.any()
4180
+ // Metadata type from API
4181
+ })
3523
4182
  }
4183
+ }, async (request, reply) => {
4184
+ const { sessionId, metadata } = request.body;
4185
+ logger.debug(`[CONTROL SERVER] Session started: ${sessionId}`);
4186
+ onHappySessionWebhook(sessionId, metadata);
4187
+ return { status: "ok" };
3524
4188
  });
3525
- socket.on("disconnect", (reason) => {
3526
- logger.debug(`[DAEMON SESSION] Disconnected from server. Reason: ${reason}`);
3527
- this.emit("disconnected");
3528
- this.stopKeepAlive();
4189
+ typed.post("/list", async (request, reply) => {
4190
+ const children = getChildren();
4191
+ logger.debug(`[CONTROL SERVER] Listing ${children.length} sessions`);
4192
+ return { children };
3529
4193
  });
3530
- socket.on("reconnect", () => {
3531
- logger.debug("[DAEMON SESSION] Reconnected to server");
3532
- const rpcMethod = `${this.machineIdentity.machineId}:spawn-happy-session`;
3533
- socket.emit("rpc-register", { method: rpcMethod });
3534
- logger.debug(`[DAEMON SESSION] Re-registered RPC method: ${rpcMethod}`);
3535
- });
3536
- socket.on("rpc-registered", (data) => {
3537
- logger.debug(`[DAEMON SESSION] RPC registration confirmed: ${data.method}`);
3538
- });
3539
- socket.on("rpc-unregistered", (data) => {
3540
- logger.debug(`[DAEMON SESSION] RPC unregistered: ${data.method}`);
3541
- });
3542
- socket.on("rpc-error", (data) => {
3543
- logger.debug(`[DAEMON SESSION] RPC error: ${JSON.stringify(data)}`);
3544
- });
3545
- socket.onAny((event, ...args) => {
3546
- if (!event.startsWith("session-alive") && event !== "ephemeral") {
3547
- logger.debug(`[DAEMON SESSION] Socket event: ${event}, args: ${JSON.stringify(args)}`);
4194
+ typed.post("/stop-session", {
4195
+ schema: {
4196
+ body: z$1.object({
4197
+ sessionId: z$1.string()
4198
+ })
3548
4199
  }
4200
+ }, async (request, reply) => {
4201
+ const { sessionId } = request.body;
4202
+ logger.debug(`[CONTROL SERVER] Stop session request: ${sessionId}`);
4203
+ const success = stopSession(sessionId);
4204
+ return { success };
3549
4205
  });
3550
- socket.on("connect_error", (error) => {
3551
- logger.debug(`[DAEMON SESSION] Connection error: ${error.message}`);
3552
- logger.debug(`[DAEMON SESSION] Error: ${JSON.stringify(error, null, 2)}`);
4206
+ typed.post("/spawn-session", {
4207
+ schema: {
4208
+ body: z$1.object({
4209
+ directory: z$1.string(),
4210
+ sessionId: z$1.string().optional()
4211
+ })
4212
+ }
4213
+ }, async (request, reply) => {
4214
+ const { directory, sessionId } = request.body;
4215
+ logger.debug(`[CONTROL SERVER] Spawn session request: dir=${directory}, sessionId=${sessionId || "new"}`);
4216
+ const session = await spawnSession(directory, sessionId);
4217
+ if (session) {
4218
+ return {
4219
+ success: true,
4220
+ pid: session.pid,
4221
+ sessionId: session.happySessionId || "pending"
4222
+ };
4223
+ } else {
4224
+ reply.code(500);
4225
+ return { error: "Failed to spawn session" };
4226
+ }
3553
4227
  });
3554
- socket.on("error", (error) => {
3555
- logger.debug(`[DAEMON SESSION] Socket error: ${error}`);
4228
+ typed.post("/stop", async (request, reply) => {
4229
+ logger.debug("[CONTROL SERVER] Stop daemon request received");
4230
+ setTimeout(() => {
4231
+ logger.debug("[CONTROL SERVER] Triggering daemon shutdown");
4232
+ requestShutdown();
4233
+ }, 50);
4234
+ return { status: "stopping" };
3556
4235
  });
3557
- socket.on("daemon-command", (data) => {
3558
- switch (data.command) {
3559
- case "shutdown":
3560
- this.shutdown();
3561
- break;
3562
- case "status":
3563
- this.emit("status-request");
3564
- break;
4236
+ app.listen({ port: 0, host: "127.0.0.1" }, (err, address) => {
4237
+ if (err) {
4238
+ logger.debug("[CONTROL SERVER] Failed to start:", err);
4239
+ throw err;
3565
4240
  }
4241
+ const port = parseInt(address.split(":").pop());
4242
+ logger.debug(`[CONTROL SERVER] Started on port ${port}`);
4243
+ resolve({
4244
+ port,
4245
+ stop: async () => {
4246
+ logger.debug("[CONTROL SERVER] Stopping server");
4247
+ await app.close();
4248
+ logger.debug("[CONTROL SERVER] Server stopped");
4249
+ }
4250
+ });
3566
4251
  });
3567
- this.socket = socket;
3568
- }
3569
- async registerMachine() {
4252
+ });
4253
+ }
4254
+
4255
+ async function startDaemon() {
4256
+ logger.debug("[DAEMON RUN] Starting daemon process...");
4257
+ logger.debugLargeJson("[DAEMON RUN] Environment", getEnvironmentInfo());
4258
+ const runningDaemon = await getDaemonState();
4259
+ if (runningDaemon) {
3570
4260
  try {
3571
- const metadata = {
3572
- host: this.machineIdentity.machineHost,
3573
- platform: this.machineIdentity.platform,
3574
- happyCliVersion: this.machineIdentity.happyCliVersion,
3575
- happyHomeDirectory: this.machineIdentity.happyHomeDirectory
3576
- };
3577
- const encrypted = encrypt(JSON.stringify(metadata), this.secret);
3578
- const encryptedMetadata = encodeBase64(encrypted);
3579
- const response = await fetch(`${configuration.serverUrl}/v1/machines`, {
3580
- method: "POST",
3581
- headers: {
3582
- "Authorization": `Bearer ${this.token}`,
3583
- "Content-Type": "application/json"
3584
- },
3585
- body: JSON.stringify({
3586
- id: this.machineIdentity.machineId,
3587
- metadata: encryptedMetadata
3588
- })
3589
- });
3590
- if (response.ok) {
3591
- logger.debug("[DAEMON SESSION] Machine registered/updated successfully");
3592
- this.machineRegistered = true;
3593
- } else {
3594
- logger.debug(`[DAEMON SESSION] Failed to register machine: ${response.status}`);
3595
- }
3596
- } catch (error) {
3597
- logger.debug("[DAEMON SESSION] Failed to register machine:", error);
4261
+ process.kill(runningDaemon.pid, 0);
4262
+ logger.debug("[DAEMON RUN] Daemon already running");
4263
+ process.exit(0);
4264
+ } catch {
4265
+ logger.debug("[DAEMON RUN] Stale state found, cleaning up");
4266
+ await cleanupDaemonState();
3598
4267
  }
3599
4268
  }
3600
- startKeepAlive() {
3601
- this.stopKeepAlive();
3602
- this.keepAliveInterval = setInterval(() => {
3603
- const payload = {
3604
- machineId: this.machineIdentity.machineId,
3605
- time: Date.now()
3606
- };
3607
- logger.debugLargeJson(`[DAEMON SESSION] Emitting machine-alive`, payload);
3608
- this.socket.emit("machine-alive", payload);
3609
- }, 2e4);
3610
- }
3611
- stopKeepAlive() {
3612
- if (this.keepAliveInterval) {
3613
- clearInterval(this.keepAliveInterval);
3614
- this.keepAliveInterval = null;
3615
- }
4269
+ const caffeinateStarted = startCaffeinate();
4270
+ if (caffeinateStarted) {
4271
+ logger.debug("[DAEMON RUN] Sleep prevention enabled");
3616
4272
  }
3617
- updateChildPidsInMetadata() {
3618
- try {
3619
- if (existsSync$1(configuration.daemonMetadataFile)) {
3620
- const content = readFileSync$1(configuration.daemonMetadataFile, "utf-8");
3621
- const metadata = JSON.parse(content);
3622
- const childPids = Array.from(this.spawnedProcesses).map((proc) => proc.pid).filter((pid) => pid !== void 0);
3623
- metadata.childPids = childPids;
3624
- writeFileSync(configuration.daemonMetadataFile, JSON.stringify(metadata, null, 2));
4273
+ try {
4274
+ const { credentials, machineId } = await authAndSetupMachineIfNeeded();
4275
+ logger.debug("[DAEMON RUN] Auth and machine setup complete");
4276
+ const pidToTrackedSession = /* @__PURE__ */ new Map();
4277
+ const pidToAwaiter = /* @__PURE__ */ new Map();
4278
+ const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
4279
+ const onHappySessionWebhook = (sessionId, sessionMetadata) => {
4280
+ const pid = sessionMetadata.hostPid;
4281
+ if (!pid) {
4282
+ logger.debug(`[DAEMON RUN] Session webhook missing hostPid for session ${sessionId}`);
4283
+ return;
4284
+ }
4285
+ logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || "unknown"}`);
4286
+ const existingSession = pidToTrackedSession.get(pid);
4287
+ if (existingSession && existingSession.startedBy === "daemon") {
4288
+ existingSession.happySessionId = sessionId;
4289
+ existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata;
4290
+ logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`);
4291
+ const awaiter = pidToAwaiter.get(pid);
4292
+ if (awaiter) {
4293
+ pidToAwaiter.delete(pid);
4294
+ awaiter(existingSession);
4295
+ logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`);
4296
+ }
4297
+ } else if (!existingSession) {
4298
+ const trackedSession = {
4299
+ startedBy: "happy directly - likely by user from terminal",
4300
+ happySessionId: sessionId,
4301
+ happySessionMetadataFromLocalWebhook: sessionMetadata,
4302
+ pid
4303
+ };
4304
+ pidToTrackedSession.set(pid, trackedSession);
4305
+ logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`);
3625
4306
  }
3626
- } catch (error) {
3627
- logger.debug("[DAEMON SESSION] Error updating child PIDs in metadata:", error);
3628
- }
3629
- }
3630
- connect() {
3631
- this.socket.connect();
3632
- }
3633
- updateMetadata(updates) {
3634
- const metadata = {
3635
- host: this.machineIdentity.machineHost,
3636
- platform: this.machineIdentity.platform,
3637
- happyCliVersion: updates.happyCliVersion || this.machineIdentity.happyCliVersion,
3638
- happyHomeDirectory: updates.happyHomeDirectory || this.machineIdentity.happyHomeDirectory
3639
4307
  };
3640
- const encrypted = encrypt(JSON.stringify(metadata), this.secret);
3641
- const encryptedMetadata = encodeBase64(encrypted);
3642
- this.socket.emit("update-machine", { metadata: encryptedMetadata });
3643
- }
3644
- shutdown() {
3645
- logger.debug(`[DAEMON SESSION] Shutting down daemon, killing ${this.spawnedProcesses.size} spawned processes`);
3646
- for (const process of this.spawnedProcesses) {
4308
+ const spawnSession = async (directory, sessionId) => {
3647
4309
  try {
3648
- logger.debug(`[DAEMON SESSION] Killing spawned process with PID: ${process.pid}`);
3649
- process.kill("SIGTERM");
3650
- setTimeout(() => {
3651
- try {
3652
- process.kill("SIGKILL");
3653
- } catch (e) {
4310
+ const happyBinPath = join$1(projectPath(), "bin", "happy.mjs");
4311
+ const args = [
4312
+ "--happy-starting-mode",
4313
+ "remote",
4314
+ "--started-by",
4315
+ "daemon"
4316
+ ];
4317
+ const fullCommand = `${happyBinPath} ${args.join(" ")}`;
4318
+ logger.debug(`[DAEMON RUN] Spawning: ${fullCommand} in ${directory}`);
4319
+ const happyProcess = spawn$1(happyBinPath, args, {
4320
+ cwd: directory,
4321
+ detached: true,
4322
+ // Sessions stay alive when daemon stops
4323
+ stdio: ["ignore", "pipe", "pipe"]
4324
+ // Capture stdout/stderr for debugging
4325
+ // env is inherited automatically from parent process
4326
+ });
4327
+ happyProcess.stdout?.on("data", (data) => {
4328
+ logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`);
4329
+ });
4330
+ happyProcess.stderr?.on("data", (data) => {
4331
+ logger.debug(`[DAEMON RUN] Child stderr: ${data.toString()}`);
4332
+ });
4333
+ if (!happyProcess.pid) {
4334
+ logger.debug("[DAEMON RUN] Failed to spawn process - no PID returned");
4335
+ return null;
4336
+ }
4337
+ logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`);
4338
+ const trackedSession = {
4339
+ startedBy: "daemon",
4340
+ pid: happyProcess.pid,
4341
+ childProcess: happyProcess
4342
+ };
4343
+ pidToTrackedSession.set(happyProcess.pid, trackedSession);
4344
+ happyProcess.on("exit", (code, signal) => {
4345
+ logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`);
4346
+ if (happyProcess.pid) {
4347
+ onChildExited(happyProcess.pid);
3654
4348
  }
3655
- }, 1e3);
4349
+ });
4350
+ happyProcess.on("error", (error) => {
4351
+ logger.debug(`[DAEMON RUN] Child process error:`, error);
4352
+ if (happyProcess.pid) {
4353
+ onChildExited(happyProcess.pid);
4354
+ }
4355
+ });
4356
+ logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`);
4357
+ return new Promise((resolve, reject) => {
4358
+ const timeout = setTimeout(() => {
4359
+ pidToAwaiter.delete(happyProcess.pid);
4360
+ logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`);
4361
+ resolve(trackedSession);
4362
+ }, 1e4);
4363
+ pidToAwaiter.set(happyProcess.pid, (completedSession) => {
4364
+ clearTimeout(timeout);
4365
+ logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`);
4366
+ resolve(completedSession);
4367
+ });
4368
+ });
3656
4369
  } catch (error) {
3657
- logger.debug(`[DAEMON SESSION] Error killing process: ${error}`);
4370
+ logger.debug("[DAEMON RUN] Failed to spawn session:", error);
4371
+ return null;
3658
4372
  }
3659
- }
3660
- this.spawnedProcesses.clear();
3661
- this.updateChildPidsInMetadata();
3662
- this.stopKeepAlive();
3663
- this.socket.close();
3664
- this.emit("shutdown");
3665
- }
3666
- }
3667
-
3668
- async function startDaemon() {
3669
- if (process.platform !== "darwin") {
3670
- console.error("ERROR: Daemon is only supported on macOS");
3671
- process.exit(1);
3672
- }
3673
- logger.debug("[DAEMON RUN] Starting daemon process...");
3674
- logger.debug(`[DAEMON RUN] Server URL: ${configuration.serverUrl}`);
3675
- const runningDaemon = await getDaemonMetadata();
3676
- if (runningDaemon) {
3677
- if (runningDaemon.version !== packageJson.version) {
3678
- logger.debug(`[DAEMON RUN] Daemon version mismatch (running: ${runningDaemon.version}, current: ${packageJson.version}), restarting...`);
3679
- await stopDaemon();
3680
- await new Promise((resolve) => setTimeout(resolve, 500));
3681
- } else if (await isDaemonProcessRunning(runningDaemon.pid)) {
3682
- logger.debug("[DAEMON RUN] Happy daemon is already running with correct version");
3683
- process.exit(0);
3684
- } else {
3685
- logger.debug("[DAEMON RUN] Stale daemon metadata found, cleaning up");
3686
- await cleanupDaemonMetadata();
3687
- }
3688
- }
3689
- const oldMetadata = await getDaemonMetadata();
3690
- if (oldMetadata && oldMetadata.childPids && oldMetadata.childPids.length > 0) {
3691
- logger.debug(`[DAEMON RUN] Found ${oldMetadata.childPids.length} potential orphaned child processes from previous run`);
3692
- for (const childPid of oldMetadata.childPids) {
3693
- try {
3694
- process.kill(childPid, 0);
3695
- const isHappy = await isProcessHappyChild(childPid);
3696
- if (isHappy) {
3697
- logger.debug(`[DAEMON RUN] Killing orphaned happy process ${childPid}`);
3698
- process.kill(childPid, "SIGTERM");
3699
- await new Promise((resolve) => setTimeout(resolve, 500));
3700
- try {
3701
- process.kill(childPid, 0);
3702
- process.kill(childPid, "SIGKILL");
3703
- } catch {
4373
+ };
4374
+ const stopSession = (sessionId) => {
4375
+ logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`);
4376
+ for (const [pid, session] of pidToTrackedSession.entries()) {
4377
+ if (session.happySessionId === sessionId || sessionId.startsWith("PID-") && pid === parseInt(sessionId.replace("PID-", ""))) {
4378
+ if (session.startedBy === "daemon" && session.childProcess) {
4379
+ try {
4380
+ session.childProcess.kill("SIGTERM");
4381
+ logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`);
4382
+ } catch (error) {
4383
+ logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error);
4384
+ }
4385
+ } else {
4386
+ try {
4387
+ process.kill(pid, "SIGTERM");
4388
+ logger.debug(`[DAEMON RUN] Sent SIGTERM to external session PID ${pid}`);
4389
+ } catch (error) {
4390
+ logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error);
4391
+ }
3704
4392
  }
4393
+ pidToTrackedSession.delete(pid);
4394
+ logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`);
4395
+ return true;
3705
4396
  }
3706
- } catch {
3707
- logger.debug(`[DAEMON RUN] Process ${childPid} doesn't exist (already dead)`);
3708
4397
  }
3709
- }
3710
- }
3711
- writeDaemonMetadata();
3712
- logger.debug("[DAEMON RUN] Daemon metadata written");
3713
- const caffeinateStarted = startCaffeinate();
3714
- if (caffeinateStarted) {
3715
- logger.debug("[DAEMON RUN] Sleep prevention enabled for daemon");
3716
- }
3717
- try {
3718
- const settings = await readSettings() || { onboardingCompleted: false };
3719
- if (!settings.machineId) {
3720
- settings.machineId = crypto.randomUUID();
3721
- settings.machineHost = hostname();
3722
- await writeSettings(settings);
3723
- }
3724
- const machineIdentity = {
3725
- machineId: settings.machineId,
3726
- machineHost: settings.machineHost || hostname(),
3727
- platform: process.platform,
3728
- happyCliVersion: packageJson.version,
3729
- happyHomeDirectory: process.cwd()
4398
+ logger.debug(`[DAEMON RUN] Session ${sessionId} not found`);
4399
+ return false;
3730
4400
  };
3731
- let credentials = await readCredentials();
3732
- if (!credentials) {
3733
- logger.debug("[DAEMON RUN] No credentials found, running auth");
3734
- await doAuth();
3735
- credentials = await readCredentials();
3736
- if (!credentials) {
3737
- throw new Error("Failed to authenticate");
3738
- }
3739
- }
3740
- const { token, secret } = credentials;
3741
- const daemon = new ApiDaemonSession(
3742
- token,
3743
- secret,
3744
- machineIdentity
3745
- );
3746
- daemon.on("connected", () => {
3747
- logger.debug("[DAEMON RUN] Connected to server event received");
3748
- });
3749
- daemon.on("disconnected", () => {
3750
- logger.debug("[DAEMON RUN] Disconnected from server event received");
4401
+ const onChildExited = (pid) => {
4402
+ logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`);
4403
+ pidToTrackedSession.delete(pid);
4404
+ };
4405
+ let requestShutdown;
4406
+ let resolvesWhenShutdownRequested = new Promise((resolve) => {
4407
+ requestShutdown = resolve;
3751
4408
  });
3752
- daemon.on("shutdown", () => {
3753
- logger.debug("[DAEMON RUN] Shutdown requested");
3754
- daemon?.shutdown();
3755
- cleanupDaemonMetadata();
3756
- process.exit(0);
4409
+ const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({
4410
+ getChildren: getCurrentChildren,
4411
+ stopSession,
4412
+ spawnSession,
4413
+ requestShutdown: () => requestShutdown("happy-cli"),
4414
+ onHappySessionWebhook
3757
4415
  });
3758
- daemon.connect();
3759
- logger.debug("[DAEMON RUN] Daemon started successfully");
3760
- process.on("SIGINT", async () => {
3761
- logger.debug("[DAEMON RUN] Received SIGINT, shutting down...");
3762
- if (daemon) {
3763
- daemon.shutdown();
3764
- }
3765
- await cleanupDaemonMetadata();
3766
- process.exit(0);
4416
+ const fileState = {
4417
+ pid: process.pid,
4418
+ httpPort: controlPort,
4419
+ startTime: (/* @__PURE__ */ new Date()).toISOString(),
4420
+ startedWithCliVersion: packageJson.version
4421
+ };
4422
+ await writeDaemonState(fileState);
4423
+ logger.debug("[DAEMON RUN] Daemon state written");
4424
+ const initialMetadata = {
4425
+ host: os$1.hostname(),
4426
+ platform: os$1.platform(),
4427
+ happyCliVersion: packageJson.version,
4428
+ homeDir: os$1.homedir(),
4429
+ happyHomeDir: configuration.happyHomeDir
4430
+ };
4431
+ const initialDaemonState = {
4432
+ status: "offline",
4433
+ pid: process.pid,
4434
+ httpPort: controlPort,
4435
+ startedAt: Date.now()
4436
+ };
4437
+ const api = new ApiClient(credentials.token, credentials.secret);
4438
+ const machine = await api.createOrReturnExistingAsIs({
4439
+ machineId,
4440
+ metadata: initialMetadata,
4441
+ daemonState: initialDaemonState
3767
4442
  });
3768
- process.on("SIGTERM", async () => {
3769
- logger.debug("[DAEMON RUN] Received SIGTERM, shutting down...");
3770
- if (daemon) {
3771
- daemon.shutdown();
3772
- }
3773
- await cleanupDaemonMetadata();
3774
- process.exit(0);
4443
+ logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`);
4444
+ const apiMachine = api.machineSyncClient(machine);
4445
+ apiMachine.setRPCHandlers({
4446
+ spawnSession,
4447
+ stopSession,
4448
+ requestShutdown: () => requestShutdown("happy-app")
3775
4449
  });
3776
- } catch (error) {
3777
- logger.debug("[DAEMON RUN] Failed to start daemon", error);
3778
- stopDaemon();
3779
- process.exit(1);
3780
- }
3781
- while (true) {
3782
- await new Promise((resolve) => setTimeout(resolve, 1e3));
3783
- }
3784
- }
3785
- async function isDaemonRunning() {
3786
- try {
3787
- logger.debug("[DAEMON RUN] [isDaemonRunning] Checking if daemon is running...");
3788
- const metadata = await getDaemonMetadata();
3789
- if (!metadata) {
3790
- logger.debug("[DAEMON RUN] [isDaemonRunning] No daemon metadata found");
3791
- return false;
3792
- }
3793
- logger.debug("[DAEMON RUN] [isDaemonRunning] Daemon metadata exists");
3794
- logger.debug("[DAEMON RUN] [isDaemonRunning] PID from metadata:", metadata.pid);
3795
- const isRunning = await isDaemonProcessRunning(metadata.pid);
3796
- if (!isRunning) {
3797
- logger.debug("[DAEMON RUN] [isDaemonRunning] Process not running, cleaning up stale metadata");
3798
- await cleanupDaemonMetadata();
3799
- return false;
3800
- }
3801
- return true;
3802
- } catch (error) {
3803
- logger.debug("[DAEMON RUN] [isDaemonRunning] Error:", error);
3804
- logger.debug("Error checking daemon status", error);
3805
- return false;
3806
- }
3807
- }
3808
- async function isDaemonProcessRunning(pid) {
3809
- try {
3810
- process.kill(pid, 0);
3811
- logger.debug("[DAEMON RUN] Process exists, checking if it's a happy daemon...");
3812
- const isHappyDaemon = await isProcessHappyDaemon(pid);
3813
- logger.debug("[DAEMON RUN] isHappyDaemon:", isHappyDaemon);
3814
- return isHappyDaemon;
3815
- } catch (error) {
3816
- return false;
3817
- }
3818
- }
3819
- function writeDaemonMetadata(childPids) {
3820
- const happyDir = join$1(homedir$1(), ".happy");
3821
- if (!existsSync$1(happyDir)) {
3822
- mkdirSync$1(happyDir, { recursive: true });
3823
- }
3824
- const metadata = {
3825
- pid: process.pid,
3826
- startTime: (/* @__PURE__ */ new Date()).toISOString(),
3827
- version: packageJson.version,
3828
- ...childPids
3829
- };
3830
- writeFileSync(configuration.daemonMetadataFile, JSON.stringify(metadata, null, 2));
3831
- }
3832
- async function getDaemonMetadata() {
3833
- try {
3834
- if (!existsSync$1(configuration.daemonMetadataFile)) {
3835
- return null;
3836
- }
3837
- const content = readFileSync$1(configuration.daemonMetadataFile, "utf-8");
3838
- return JSON.parse(content);
3839
- } catch (error) {
3840
- logger.debug("Error reading daemon metadata", error);
3841
- return null;
3842
- }
3843
- }
3844
- async function cleanupDaemonMetadata() {
3845
- try {
3846
- if (existsSync$1(configuration.daemonMetadataFile)) {
3847
- unlinkSync(configuration.daemonMetadataFile);
3848
- }
3849
- } catch (error) {
3850
- logger.debug("Error cleaning up daemon metadata", error);
3851
- }
3852
- }
3853
- async function stopDaemon() {
3854
- try {
3855
- stopCaffeinate();
3856
- logger.debug("Stopped sleep prevention");
3857
- const metadata = await getDaemonMetadata();
3858
- if (metadata) {
3859
- logger.debug(`Stopping daemon with PID ${metadata.pid}`);
3860
- try {
3861
- process.kill(metadata.pid, "SIGTERM");
3862
- await new Promise((resolve) => setTimeout(resolve, 2e3));
3863
- try {
3864
- process.kill(metadata.pid, 0);
3865
- logger.debug("Daemon still running, force killing...");
3866
- process.kill(metadata.pid, "SIGKILL");
3867
- } catch {
3868
- logger.debug("Daemon exited cleanly");
3869
- }
3870
- } catch (error) {
3871
- logger.debug("Daemon process already dead or inaccessible", error);
4450
+ apiMachine.connect();
4451
+ const cleanupAndShutdown = async (source) => {
4452
+ logger.debug(`[DAEMON RUN] Starting cleanup (source: ${source})...`);
4453
+ if (apiMachine) {
4454
+ await apiMachine.updateDaemonState((state) => ({
4455
+ ...state,
4456
+ status: "shutting-down",
4457
+ shutdownRequestedAt: Date.now(),
4458
+ shutdownSource: source === "happy-app" ? "mobile-app" : source === "happy-cli" ? "cli" : source
4459
+ }));
4460
+ await new Promise((resolve) => setTimeout(resolve, 100));
3872
4461
  }
3873
- await new Promise((resolve) => setTimeout(resolve, 500));
3874
- if (metadata.childPids && metadata.childPids.length > 0) {
3875
- logger.debug(`Checking for ${metadata.childPids.length} potential orphaned child processes...`);
3876
- for (const childPid of metadata.childPids) {
3877
- try {
3878
- process.kill(childPid, 0);
3879
- const isHappy = await isProcessHappyChild(childPid);
3880
- if (isHappy) {
3881
- logger.debug(`Killing orphaned happy process ${childPid}`);
3882
- process.kill(childPid, "SIGTERM");
3883
- await new Promise((resolve) => setTimeout(resolve, 500));
3884
- try {
3885
- process.kill(childPid, 0);
3886
- process.kill(childPid, "SIGKILL");
3887
- } catch {
3888
- }
3889
- }
3890
- } catch {
3891
- }
3892
- }
4462
+ if (apiMachine) {
4463
+ apiMachine.shutdown();
3893
4464
  }
3894
- await cleanupDaemonMetadata();
3895
- }
3896
- } catch (error) {
3897
- logger.debug("Error stopping daemon", error);
3898
- }
3899
- }
3900
- async function isProcessHappyDaemon(pid) {
3901
- return new Promise((resolve) => {
3902
- const ps = spawn$1("ps", ["-p", pid.toString(), "-o", "command="]);
3903
- let output = "";
3904
- ps.stdout.on("data", (data) => {
3905
- output += data.toString();
3906
- });
3907
- ps.on("close", () => {
3908
- const isHappyDaemon = output.includes("daemon start") && (output.includes("happy") || output.includes("src/index"));
3909
- resolve(isHappyDaemon);
4465
+ logger.debug("[DAEMON RUN] Machine session shutdown");
4466
+ await stopControlServer();
4467
+ logger.debug("[DAEMON RUN] Control server stopped");
4468
+ await cleanupDaemonState();
4469
+ logger.debug("[DAEMON RUN] State cleaned up");
4470
+ stopCaffeinate();
4471
+ logger.debug("[DAEMON RUN] Caffeinate stopped");
4472
+ process.exit(0);
4473
+ };
4474
+ process.on("SIGINT", () => {
4475
+ logger.debug("[DAEMON RUN] Received SIGINT");
4476
+ cleanupAndShutdown("os-signal");
3910
4477
  });
3911
- ps.on("error", () => {
3912
- resolve(false);
4478
+ process.on("SIGTERM", () => {
4479
+ logger.debug("[DAEMON RUN] Received SIGTERM");
4480
+ cleanupAndShutdown("os-signal");
3913
4481
  });
3914
- });
3915
- }
3916
- async function isProcessHappyChild(pid) {
3917
- return new Promise((resolve) => {
3918
- const ps = spawn$1("ps", ["-p", pid.toString(), "-o", "command="]);
3919
- let output = "";
3920
- ps.stdout.on("data", (data) => {
3921
- output += data.toString();
4482
+ process.on("uncaughtException", (error) => {
4483
+ logger.debug("[DAEMON RUN] Uncaught exception - cleaning up before crash", error);
4484
+ cleanupAndShutdown("unknown");
3922
4485
  });
3923
- ps.on("close", () => {
3924
- const isHappyChild = output.includes("--daemon-spawn") && (output.includes("happy") || output.includes("src/index"));
3925
- resolve(isHappyChild);
4486
+ process.on("unhandledRejection", (reason) => {
4487
+ logger.debug("[DAEMON RUN] Unhandled rejection - cleaning up before crash", reason);
4488
+ cleanupAndShutdown("unknown");
3926
4489
  });
3927
- ps.on("error", () => {
3928
- resolve(false);
4490
+ process.on("exit", () => {
4491
+ logger.debug("[DAEMON RUN] Process exit, not killing any children");
3929
4492
  });
3930
- });
4493
+ logger.debug("[DAEMON RUN] Daemon started successfully");
4494
+ const shutdownSource = await resolvesWhenShutdownRequested;
4495
+ logger.debug(`[DAEMON RUN] Shutdown requested (source: ${shutdownSource})`);
4496
+ await cleanupAndShutdown(shutdownSource);
4497
+ } catch (error) {
4498
+ logger.debug("[DAEMON RUN] Failed to start daemon", error);
4499
+ await cleanupDaemonState();
4500
+ stopCaffeinate();
4501
+ process.exit(1);
4502
+ }
3931
4503
  }
3932
4504
 
3933
4505
  function trimIdent(text) {
@@ -3951,7 +4523,6 @@ function trimIdent(text) {
3951
4523
 
3952
4524
  const PLIST_LABEL$1 = "com.happy-cli.daemon";
3953
4525
  const PLIST_FILE$1 = `/Library/LaunchDaemons/${PLIST_LABEL$1}.plist`;
3954
- const USER_HOME = process.env.HOME || process.env.USERPROFILE;
3955
4526
  async function install$1() {
3956
4527
  try {
3957
4528
  if (existsSync$1(PLIST_FILE$1)) {
@@ -3988,10 +4559,10 @@ async function install$1() {
3988
4559
  <true/>
3989
4560
 
3990
4561
  <key>StandardErrorPath</key>
3991
- <string>${USER_HOME}/.happy/daemon.err</string>
4562
+ <string>${os$1.homedir()}/.happy/daemon.err</string>
3992
4563
 
3993
4564
  <key>StandardOutPath</key>
3994
- <string>${USER_HOME}/.happy/daemon.log</string>
4565
+ <string>${os$1.homedir()}/.happy/daemon.log</string>
3995
4566
 
3996
4567
  <key>WorkingDirectory</key>
3997
4568
  <string>/tmp</string>
@@ -4055,16 +4626,249 @@ async function uninstall() {
4055
4626
  await uninstall$1();
4056
4627
  }
4057
4628
 
4629
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
4630
+ function bytesToBase32(bytes) {
4631
+ let result = "";
4632
+ let buffer = 0;
4633
+ let bufferLength = 0;
4634
+ for (const byte of bytes) {
4635
+ buffer = buffer << 8 | byte;
4636
+ bufferLength += 8;
4637
+ while (bufferLength >= 5) {
4638
+ bufferLength -= 5;
4639
+ result += BASE32_ALPHABET[buffer >> bufferLength & 31];
4640
+ }
4641
+ }
4642
+ if (bufferLength > 0) {
4643
+ result += BASE32_ALPHABET[buffer << 5 - bufferLength & 31];
4644
+ }
4645
+ return result;
4646
+ }
4647
+ function formatSecretKeyForBackup(secretBytes) {
4648
+ const base32 = bytesToBase32(secretBytes);
4649
+ const groups = [];
4650
+ for (let i = 0; i < base32.length; i += 5) {
4651
+ groups.push(base32.slice(i, i + 5));
4652
+ }
4653
+ return groups.join("-");
4654
+ }
4655
+
4656
+ async function handleAuthCommand(args) {
4657
+ const subcommand = args[0];
4658
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
4659
+ showAuthHelp();
4660
+ return;
4661
+ }
4662
+ switch (subcommand) {
4663
+ case "login":
4664
+ await handleAuthLogin(args.slice(1));
4665
+ break;
4666
+ case "logout":
4667
+ await handleAuthLogout();
4668
+ break;
4669
+ case "show-backup":
4670
+ await handleAuthShowBackup();
4671
+ break;
4672
+ case "status":
4673
+ await handleAuthStatus();
4674
+ break;
4675
+ default:
4676
+ console.error(chalk.red(`Unknown auth subcommand: ${subcommand}`));
4677
+ showAuthHelp();
4678
+ process.exit(1);
4679
+ }
4680
+ }
4681
+ function showAuthHelp() {
4682
+ console.log(`
4683
+ ${chalk.bold("happy auth")} - Authentication management
4684
+
4685
+ ${chalk.bold("Usage:")}
4686
+ happy auth login [--force] Authenticate with Happy
4687
+ happy auth logout Remove authentication and machine data
4688
+ happy auth status Show authentication status
4689
+ happy auth show-backup Display backup key for mobile/web clients
4690
+ happy auth help Show this help message
4691
+
4692
+ ${chalk.bold("Options:")}
4693
+ --force Clear credentials, machine ID, and stop daemon before re-auth
4694
+
4695
+ ${chalk.bold("Examples:")}
4696
+ happy auth login Authenticate if not already logged in
4697
+ happy auth login --force Force re-authentication (complete reset)
4698
+ happy auth status Check authentication and machine status
4699
+ happy auth show-backup Get backup key to link other devices
4700
+ happy auth logout Remove all authentication data
4701
+
4702
+ ${chalk.bold("Notes:")}
4703
+ \u2022 Use 'auth login --force' when you need to re-register your machine
4704
+ \u2022 'auth show-backup' displays the key format expected by mobile/web clients
4705
+ \u2022 The backup key allows linking multiple devices to the same account
4706
+ `);
4707
+ }
4708
+ async function handleAuthLogin(args) {
4709
+ const forceAuth = args.includes("--force") || args.includes("-f");
4710
+ if (forceAuth) {
4711
+ console.log(chalk.yellow("Force authentication requested."));
4712
+ console.log(chalk.gray("This will:"));
4713
+ console.log(chalk.gray(" \u2022 Clear existing credentials"));
4714
+ console.log(chalk.gray(" \u2022 Clear machine ID"));
4715
+ console.log(chalk.gray(" \u2022 Stop daemon if running"));
4716
+ console.log(chalk.gray(" \u2022 Re-authenticate and register machine\n"));
4717
+ try {
4718
+ logger.debug("Stopping daemon for force auth...");
4719
+ await stopDaemon();
4720
+ console.log(chalk.gray("\u2713 Stopped daemon"));
4721
+ } catch (error) {
4722
+ logger.debug("Daemon was not running or failed to stop:", error);
4723
+ }
4724
+ await clearCredentials();
4725
+ console.log(chalk.gray("\u2713 Cleared credentials"));
4726
+ await clearMachineId();
4727
+ console.log(chalk.gray("\u2713 Cleared machine ID"));
4728
+ console.log("");
4729
+ }
4730
+ if (!forceAuth) {
4731
+ const existingCreds = await readCredentials();
4732
+ const settings = await readSettings();
4733
+ if (existingCreds && settings?.machineId) {
4734
+ console.log(chalk.green("\u2713 Already authenticated"));
4735
+ console.log(chalk.gray(` Machine ID: ${settings.machineId}`));
4736
+ console.log(chalk.gray(` Host: ${os.hostname()}`));
4737
+ console.log(chalk.gray(` Use 'happy auth login --force' to re-authenticate`));
4738
+ return;
4739
+ } else if (existingCreds && !settings?.machineId) {
4740
+ console.log(chalk.yellow("\u26A0\uFE0F Credentials exist but machine ID is missing"));
4741
+ console.log(chalk.gray(" This can happen if --auth flag was used previously"));
4742
+ console.log(chalk.gray(" Fixing by setting up machine...\n"));
4743
+ }
4744
+ }
4745
+ try {
4746
+ const result = await authAndSetupMachineIfNeeded();
4747
+ console.log(chalk.green("\n\u2713 Authentication successful"));
4748
+ console.log(chalk.gray(` Machine ID: ${result.machineId}`));
4749
+ } catch (error) {
4750
+ console.error(chalk.red("Authentication failed:"), error instanceof Error ? error.message : "Unknown error");
4751
+ process.exit(1);
4752
+ }
4753
+ }
4754
+ async function handleAuthLogout() {
4755
+ const happyDir = configuration.happyHomeDir;
4756
+ const credentials = await readCredentials();
4757
+ if (!credentials) {
4758
+ console.log(chalk.yellow("Not currently authenticated"));
4759
+ return;
4760
+ }
4761
+ console.log(chalk.blue("This will log you out of Happy"));
4762
+ console.log(chalk.yellow("\u26A0\uFE0F You will need to re-authenticate to use Happy again"));
4763
+ const rl = createInterface({
4764
+ input: process.stdin,
4765
+ output: process.stdout
4766
+ });
4767
+ const answer = await new Promise((resolve) => {
4768
+ rl.question(chalk.yellow("Are you sure you want to log out? (y/N): "), resolve);
4769
+ });
4770
+ rl.close();
4771
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
4772
+ try {
4773
+ try {
4774
+ await stopDaemon();
4775
+ console.log(chalk.gray("Stopped daemon"));
4776
+ } catch {
4777
+ }
4778
+ if (existsSync(happyDir)) {
4779
+ rmSync(happyDir, { recursive: true, force: true });
4780
+ }
4781
+ console.log(chalk.green("\u2713 Successfully logged out"));
4782
+ console.log(chalk.gray(' Run "happy auth login" to authenticate again'));
4783
+ } catch (error) {
4784
+ throw new Error(`Failed to logout: ${error instanceof Error ? error.message : "Unknown error"}`);
4785
+ }
4786
+ } else {
4787
+ console.log(chalk.blue("Logout cancelled"));
4788
+ }
4789
+ }
4790
+ async function handleAuthShowBackup() {
4791
+ const credentials = await readCredentials();
4792
+ const settings = await readSettings();
4793
+ if (!credentials) {
4794
+ console.log(chalk.yellow("Not authenticated"));
4795
+ console.log(chalk.gray('Run "happy auth login" to authenticate first'));
4796
+ return;
4797
+ }
4798
+ const formattedBackupKey = formatSecretKeyForBackup(credentials.secret);
4799
+ console.log(chalk.bold("\n\u{1F4F1} Backup Key\n"));
4800
+ console.log(chalk.cyan("Your backup key:"));
4801
+ console.log(chalk.bold(formattedBackupKey));
4802
+ console.log("");
4803
+ console.log(chalk.cyan("Machine Information:"));
4804
+ console.log(` Machine ID: ${settings?.machineId || "not set"}`);
4805
+ console.log(` Host: ${os.hostname()}`);
4806
+ console.log("");
4807
+ console.log(chalk.bold("How to use this backup key:"));
4808
+ console.log(chalk.gray("\u2022 In Happy mobile app: Go to restore/link device and enter this key"));
4809
+ console.log(chalk.gray("\u2022 This key format matches what the mobile app expects"));
4810
+ console.log(chalk.gray("\u2022 You can type it with or without dashes - the app will normalize it"));
4811
+ console.log(chalk.gray("\u2022 Common typos (0\u2192O, 1\u2192I) are automatically corrected"));
4812
+ console.log("");
4813
+ console.log(chalk.yellow("\u26A0\uFE0F Keep this key secure - it provides full access to your account"));
4814
+ }
4815
+ async function handleAuthStatus() {
4816
+ const credentials = await readCredentials();
4817
+ const settings = await readSettings();
4818
+ console.log(chalk.bold("\nAuthentication Status\n"));
4819
+ if (!credentials) {
4820
+ console.log(chalk.red("\u2717 Not authenticated"));
4821
+ console.log(chalk.gray(' Run "happy auth login" to authenticate'));
4822
+ return;
4823
+ }
4824
+ console.log(chalk.green("\u2713 Authenticated"));
4825
+ const tokenPreview = credentials.token.substring(0, 30) + "...";
4826
+ console.log(chalk.gray(` Token: ${tokenPreview}`));
4827
+ if (settings?.machineId) {
4828
+ console.log(chalk.green("\u2713 Machine registered"));
4829
+ console.log(chalk.gray(` Machine ID: ${settings.machineId}`));
4830
+ console.log(chalk.gray(` Host: ${os.hostname()}`));
4831
+ } else {
4832
+ console.log(chalk.yellow("\u26A0\uFE0F Machine not registered"));
4833
+ console.log(chalk.gray(' Run "happy auth login --force" to fix this'));
4834
+ }
4835
+ console.log(chalk.gray(`
4836
+ Data directory: ${configuration.happyHomeDir}`));
4837
+ try {
4838
+ const { isDaemonRunning } = await Promise.resolve().then(function () { return utils; });
4839
+ const running = await isDaemonRunning();
4840
+ if (running) {
4841
+ console.log(chalk.green("\u2713 Daemon running"));
4842
+ } else {
4843
+ console.log(chalk.gray("\u2717 Daemon not running"));
4844
+ }
4845
+ } catch {
4846
+ console.log(chalk.gray("\u2717 Daemon not running"));
4847
+ }
4848
+ }
4849
+
4058
4850
  (async () => {
4059
4851
  const args = process.argv.slice(2);
4060
- let installationLocation = args.includes("--local") || process.env.HANDY_LOCAL ? "local" : "global";
4061
- initializeConfiguration(installationLocation);
4062
- initLoggerWithGlobalConfiguration();
4063
4852
  logger.debug("Starting happy CLI with args: ", process.argv);
4064
4853
  const subcommand = args[0];
4065
- if (subcommand === "logout") {
4854
+ if (subcommand === "doctor") {
4855
+ await runDoctorCommand();
4856
+ return;
4857
+ } else if (subcommand === "auth") {
4066
4858
  try {
4067
- await cleanKey();
4859
+ await handleAuthCommand(args.slice(1));
4860
+ } catch (error) {
4861
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
4862
+ if (process.env.DEBUG) {
4863
+ console.error(error);
4864
+ }
4865
+ process.exit(1);
4866
+ }
4867
+ return;
4868
+ } else if (subcommand === "logout") {
4869
+ console.log(chalk.yellow('Note: "happy logout" is deprecated. Use "happy auth logout" instead.\n'));
4870
+ try {
4871
+ await handleAuthCommand(["logout"]);
4068
4872
  } catch (error) {
4069
4873
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
4070
4874
  if (process.env.DEBUG) {
@@ -4086,12 +4890,92 @@ async function uninstall() {
4086
4890
  return;
4087
4891
  } else if (subcommand === "daemon") {
4088
4892
  const daemonSubcommand = args[1];
4089
- if (daemonSubcommand === "start") {
4893
+ if (daemonSubcommand === "list") {
4894
+ try {
4895
+ const sessions = await listDaemonSessions();
4896
+ if (sessions.length === 0) {
4897
+ console.log("No active sessions");
4898
+ } else {
4899
+ console.log("Active sessions:");
4900
+ const cleanSessions = sessions.map((s) => ({
4901
+ pid: s.pid,
4902
+ sessionId: s.happySessionId || `PID-${s.pid}`,
4903
+ startedBy: s.startedBy,
4904
+ directory: s.happySessionMetadataFromLocalWebhook?.directory || "unknown"
4905
+ }));
4906
+ console.log(JSON.stringify(cleanSessions, null, 2));
4907
+ }
4908
+ } catch (error) {
4909
+ console.log("No daemon running");
4910
+ }
4911
+ return;
4912
+ } else if (daemonSubcommand === "stop-session") {
4913
+ const sessionId = args[2];
4914
+ if (!sessionId) {
4915
+ console.error("Session ID required");
4916
+ process.exit(1);
4917
+ }
4918
+ try {
4919
+ const success = await stopDaemonSession(sessionId);
4920
+ console.log(success ? "Session stopped" : "Failed to stop session");
4921
+ } catch (error) {
4922
+ console.log("No daemon running");
4923
+ }
4924
+ return;
4925
+ } else if (daemonSubcommand === "start") {
4926
+ const happyBinPath = join(projectPath(), "bin", "happy.mjs");
4927
+ const child = spawn$1(happyBinPath, ["daemon", "start-sync"], {
4928
+ detached: true,
4929
+ stdio: "ignore",
4930
+ env: process.env
4931
+ });
4932
+ child.unref();
4933
+ let started = false;
4934
+ for (let i = 0; i < 50; i++) {
4935
+ if (await isDaemonRunning()) {
4936
+ started = true;
4937
+ break;
4938
+ }
4939
+ await new Promise((resolve) => setTimeout(resolve, 100));
4940
+ }
4941
+ if (started) {
4942
+ console.log("Daemon started successfully");
4943
+ } else {
4944
+ console.error("Failed to start daemon");
4945
+ process.exit(1);
4946
+ }
4947
+ process.exit(0);
4948
+ } else if (daemonSubcommand === "start-sync") {
4090
4949
  await startDaemon();
4091
4950
  process.exit(0);
4092
4951
  } else if (daemonSubcommand === "stop") {
4093
4952
  await stopDaemon();
4094
4953
  process.exit(0);
4954
+ } else if (daemonSubcommand === "status") {
4955
+ const state = await getDaemonState();
4956
+ if (!state) {
4957
+ console.log("Daemon is not running");
4958
+ } else {
4959
+ const isRunning = await isDaemonRunning();
4960
+ if (isRunning) {
4961
+ console.log("Daemon is running");
4962
+ console.log(` PID: ${state.pid}`);
4963
+ console.log(` Port: ${state.httpPort}`);
4964
+ console.log(` Started: ${new Date(state.startTime).toLocaleString()}`);
4965
+ console.log(` CLI Version: ${state.startedWithCliVersion}`);
4966
+ } else {
4967
+ console.log("Daemon state file exists but daemon is not running (stale)");
4968
+ }
4969
+ }
4970
+ process.exit(0);
4971
+ } else if (daemonSubcommand === "kill-runaway") {
4972
+ const { killRunawayHappyProcesses } = await Promise.resolve().then(function () { return utils; });
4973
+ const result = await killRunawayHappyProcesses();
4974
+ console.log(`Killed ${result.killed} runaway processes`);
4975
+ if (result.errors.length > 0) {
4976
+ console.log("Errors:", result.errors);
4977
+ }
4978
+ process.exit(0);
4095
4979
  } else if (daemonSubcommand === "install") {
4096
4980
  try {
4097
4981
  await install();
@@ -4111,13 +4995,16 @@ async function uninstall() {
4111
4995
  ${chalk.bold("happy daemon")} - Daemon management
4112
4996
 
4113
4997
  ${chalk.bold("Usage:")}
4114
- happy daemon start Start the daemon
4115
- happy daemon stop Stop the daemon
4116
- sudo happy daemon install Install the daemon (requires sudo)
4117
- sudo happy daemon uninstall Uninstall the daemon (requires sudo)
4998
+ happy daemon start Start the daemon (detached)
4999
+ happy daemon stop Stop the daemon (sessions stay alive)
5000
+ happy daemon stop --kill-managed Stop daemon and kill managed sessions
5001
+ happy daemon status Show daemon status
5002
+ happy daemon list List active sessions
5003
+ happy daemon stop-session <id> Stop a specific session
5004
+ happy daemon kill-runaway Kill all runaway Happy processes
4118
5005
 
4119
- ${chalk.bold("Note:")} The daemon runs in the background and provides persistent services.
4120
- Currently only supported on macOS.
5006
+ ${chalk.bold("Note:")} The daemon runs in the background and manages Claude sessions.
5007
+ Sessions spawned by the daemon will continue running after daemon stops unless --kill-managed is used.
4121
5008
  `);
4122
5009
  }
4123
5010
  return;
@@ -4126,104 +5013,105 @@ Currently only supported on macOS.
4126
5013
  let showHelp = false;
4127
5014
  let showVersion = false;
4128
5015
  let forceAuth = false;
5016
+ let forceAuthNew = false;
5017
+ const unknownArgs = [];
4129
5018
  for (let i = 0; i < args.length; i++) {
4130
5019
  const arg = args[i];
4131
- if (arg === "-h" || arg === "--help") {
5020
+ if (arg === "--help") {
4132
5021
  showHelp = true;
4133
- } else if (arg === "-v" || arg === "--version") {
5022
+ } else if (arg === "--version") {
4134
5023
  showVersion = true;
4135
5024
  } else if (arg === "--auth" || arg === "--login") {
4136
5025
  forceAuth = true;
4137
- } else if (arg === "-m" || arg === "--model") {
4138
- options.model = args[++i];
4139
- } else if (arg === "-p" || arg === "--permission-mode") {
4140
- options.permissionMode = z$1.enum(["default", "acceptEdits", "bypassPermissions", "plan"]).parse(args[++i]);
4141
- } else if (arg === "--local") ; else if (arg === "--happy-starting-mode") {
5026
+ } else if (arg === "--force-auth") {
5027
+ forceAuthNew = true;
5028
+ } else if (arg === "--happy-starting-mode") {
4142
5029
  options.startingMode = z$1.enum(["local", "remote"]).parse(args[++i]);
4143
- } else if (arg === "--claude-env") {
4144
- const envVar = args[++i];
4145
- const [key, value] = envVar.split("=", 2);
4146
- if (!key || value === void 0) {
4147
- console.error(chalk.red(`Invalid environment variable format: ${envVar}. Use KEY=VALUE`));
4148
- process.exit(1);
4149
- }
4150
- options.claudeEnvVars = { ...options.claudeEnvVars, [key]: value };
4151
- } else if (arg === "--claude-arg") {
4152
- const claudeArg = args[++i];
4153
- options.claudeArgs = [...options.claudeArgs || [], claudeArg];
4154
- } else if (arg === "--daemon-spawn") {
4155
- options.daemonSpawn = true;
5030
+ } else if (arg === "--yolo") {
5031
+ unknownArgs.push("--dangerously-skip-permissions");
5032
+ } else if (arg === "--started-by") {
5033
+ options.startedBy = args[++i];
4156
5034
  } else {
4157
- console.error(chalk.red(`Unknown argument: ${arg}`));
4158
- process.exit(1);
5035
+ unknownArgs.push(arg);
5036
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
5037
+ unknownArgs.push(args[++i]);
5038
+ }
4159
5039
  }
4160
5040
  }
5041
+ if (unknownArgs.length > 0) {
5042
+ options.claudeArgs = [...options.claudeArgs || [], ...unknownArgs];
5043
+ }
4161
5044
  if (showHelp) {
4162
5045
  console.log(`
4163
5046
  ${chalk.bold("happy")} - Claude Code On the Go
4164
5047
 
4165
5048
  ${chalk.bold("Usage:")}
4166
- happy [options]
4167
- happy notify Send notification
4168
- happy logout Logs out of your account and removes data directory
4169
- happy daemon Manage the background daemon (macOS only)
4170
-
4171
- ${chalk.bold("Options:")}
4172
- -h, --help Show this help message
4173
- -v, --version Show version
4174
- -m, --model <model> Claude model to use (default: sonnet)
4175
- -p, --permission-mode Permission mode: default, acceptEdits, bypassPermissions, or plan
4176
- --auth, --login Force re-authentication
4177
- --claude-env KEY=VALUE Set environment variable for Claude Code
4178
- --claude-arg ARG Pass additional argument to Claude CLI
5049
+ happy [options] Start Claude with mobile control
5050
+ happy auth Manage authentication
5051
+ happy notify Send push notification
5052
+ happy daemon Manage background service
4179
5053
 
4180
- [Daemon Management]
4181
- --happy-daemon-start Start the daemon in background
4182
- --happy-daemon-stop Stop the daemon
4183
- --happy-daemon-install Install daemon to run on startup
4184
- --happy-daemon-uninstall Uninstall daemon from startup
5054
+ ${chalk.bold("Happy Options:")}
5055
+ --help Show this help message
5056
+ --yolo Skip all permissions (--dangerously-skip-permissions)
5057
+ --force-auth Force re-authentication
4185
5058
 
4186
- [Advanced]
4187
- --local < global | local >
4188
- Will use .happy folder in the current directory for storing your private key and debug logs.
4189
- You will require re-login each time you run this in a new directory.
4190
- --happy-starting-mode <interactive|remote>
4191
- Set the starting mode for new sessions (default: remote)
4192
- --happy-server-url <url>
4193
- Set the server URL (overrides HANDY_SERVER_URL environment variable)
5059
+ ${chalk.bold("\u{1F3AF} Happy supports ALL Claude options!")}
5060
+ Use any claude flag exactly as you normally would.
4194
5061
 
4195
5062
  ${chalk.bold("Examples:")}
4196
- happy Start a session with default settings
4197
- happy -m opus Use Claude Opus model
4198
- happy -p plan Use plan permission mode
4199
- happy --auth Force re-authentication before starting session
4200
- happy notify -p "Hello!" Send notification
4201
- happy --claude-env KEY=VALUE
4202
- Set environment variable for Claude Code
4203
- happy --claude-arg --option
4204
- Pass argument to Claude CLI
4205
- happy logout Logs out of your account and removes data directory
5063
+ happy Start session
5064
+ happy --yolo Start without permissions
5065
+ happy --verbose Enable verbose mode
5066
+ happy -c Continue last conversation
5067
+ happy auth login Authenticate
5068
+ happy notify -p "Done!" Send notification
4206
5069
 
4207
- [TODO: add after steve's refactor lands]
4208
- ${chalk.bold("Happy is a seamless passthrough to Claude CLI - so any commands that Claude CLI supports will work with Happy. Here is the help for Claude CLI:")}
4209
- TODO: exec cluade --help and show inline here
5070
+ ${chalk.bold("Happy is a wrapper around Claude Code that enables remote control via mobile app.")}
5071
+ ${chalk.bold('Use "happy daemon" for background service management.')}
5072
+
5073
+ ${chalk.gray("\u2500".repeat(60))}
5074
+ ${chalk.bold.cyan("Claude Code Options (from `claude --help`):")}
4210
5075
  `);
5076
+ const { execSync } = await import('child_process');
5077
+ try {
5078
+ const claudeHelp = execSync("claude --help", { encoding: "utf8" });
5079
+ console.log(claudeHelp);
5080
+ } catch (e) {
5081
+ console.log(chalk.yellow("Could not retrieve claude help. Make sure claude is installed."));
5082
+ }
4211
5083
  process.exit(0);
4212
5084
  }
4213
5085
  if (showVersion) {
4214
5086
  console.log(packageJson.version);
4215
5087
  process.exit(0);
4216
5088
  }
4217
- let credentials = await readCredentials();
4218
- if (!credentials || forceAuth) {
4219
- let res = await doAuth();
5089
+ let credentials;
5090
+ if (forceAuthNew) {
5091
+ console.log(chalk.yellow("Force authentication requested..."));
5092
+ try {
5093
+ await stopDaemon();
5094
+ } catch {
5095
+ }
5096
+ await clearCredentials();
5097
+ await clearMachineId();
5098
+ const result = await authAndSetupMachineIfNeeded();
5099
+ credentials = result.credentials;
5100
+ } else if (forceAuth) {
5101
+ console.log(chalk.yellow('Note: --auth is deprecated. Use "happy auth login" or --force-auth instead.\n'));
5102
+ const res = await doAuth();
4220
5103
  if (!res) {
4221
5104
  process.exit(1);
4222
5105
  }
4223
- credentials = res;
5106
+ await writeCredentials(res);
5107
+ const result = await authAndSetupMachineIfNeeded();
5108
+ credentials = result.credentials;
5109
+ } else {
5110
+ const result = await authAndSetupMachineIfNeeded();
5111
+ credentials = result.credentials;
4224
5112
  }
4225
- const settings = await readSettings() || { onboardingCompleted: false };
4226
- if (settings.daemonAutoStartWhenRunningHappy === void 0) {
5113
+ let settings = await readSettings();
5114
+ if (settings && settings.daemonAutoStartWhenRunningHappy === void 0) {
4227
5115
  console.log(chalk.cyan("\n\u{1F680} Happy Daemon Setup\n"));
4228
5116
  const rl = createInterface({
4229
5117
  input: process.stdin,
@@ -4238,39 +5126,28 @@ TODO: exec cluade --help and show inline here
4238
5126
  });
4239
5127
  rl.close();
4240
5128
  const shouldAutoStart = answer.toLowerCase() !== "n";
4241
- settings.daemonAutoStartWhenRunningHappy = shouldAutoStart;
5129
+ settings = await updateSettings((settings2) => ({
5130
+ ...settings2,
5131
+ daemonAutoStartWhenRunningHappy: shouldAutoStart
5132
+ }));
4242
5133
  if (shouldAutoStart) {
4243
5134
  console.log(chalk.green("\u2713 Happy will start the background service automatically"));
4244
5135
  console.log(chalk.gray(" The service will run whenever you use the happy command"));
4245
5136
  } else {
4246
5137
  console.log(chalk.yellow(" You can enable this later by running: happy daemon install"));
4247
5138
  }
4248
- await writeSettings(settings);
4249
5139
  }
4250
- if (settings.daemonAutoStartWhenRunningHappy) {
5140
+ if (settings && settings.daemonAutoStartWhenRunningHappy) {
4251
5141
  logger.debug("Starting Happy background service...");
4252
5142
  if (!await isDaemonRunning()) {
4253
- const happyPath = process.argv[1];
4254
- const runningFromBuiltBinary = happyPath.endsWith("happy") || happyPath.endsWith("happy.cmd");
4255
- const daemonArgs = ["daemon", "start"];
4256
- if (installationLocation === "local") {
4257
- daemonArgs.push("--local");
4258
- }
4259
- let executable, args2;
4260
- if (runningFromBuiltBinary) {
4261
- executable = happyPath;
4262
- args2 = daemonArgs;
4263
- } else {
4264
- executable = "npx";
4265
- args2 = ["tsx", happyPath, ...daemonArgs];
4266
- }
4267
- const daemonProcess = spawn$1(executable, args2, {
5143
+ const happyBinPath = join(projectPath(), "bin", "happy.mjs");
5144
+ const daemonProcess = spawn$1(happyBinPath, ["daemon", "start-sync"], {
4268
5145
  detached: true,
4269
- stdio: ["ignore", "inherit", "inherit"]
4270
- // Show stdout/stderr for debugging
5146
+ stdio: "ignore",
5147
+ env: process.env
4271
5148
  });
4272
5149
  daemonProcess.unref();
4273
- await new Promise((resolve) => setTimeout(resolve, 200));
5150
+ await new Promise((resolve) => setTimeout(resolve, 500));
4274
5151
  }
4275
5152
  }
4276
5153
  try {
@@ -4284,34 +5161,6 @@ TODO: exec cluade --help and show inline here
4284
5161
  }
4285
5162
  }
4286
5163
  })();
4287
- async function cleanKey() {
4288
- const happyDir = configuration.happyDir;
4289
- if (!existsSync(happyDir)) {
4290
- console.log(chalk.yellow("No happy data directory found at:"), happyDir);
4291
- return;
4292
- }
4293
- console.log(chalk.blue("Found happy data directory at:"), happyDir);
4294
- console.log(chalk.yellow("\u26A0\uFE0F This will remove all authentication data and require reconnecting your phone."));
4295
- const rl = createInterface({
4296
- input: process.stdin,
4297
- output: process.stdout
4298
- });
4299
- const answer = await new Promise((resolve) => {
4300
- rl.question(chalk.yellow("Are you sure you want to remove the happy data directory? (y/N): "), resolve);
4301
- });
4302
- rl.close();
4303
- if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
4304
- try {
4305
- rmSync(happyDir, { recursive: true, force: true });
4306
- console.log(chalk.green("\u2713 Happy data directory removed successfully"));
4307
- console.log(chalk.blue("\u2139\uFE0F You will need to reconnect your phone on the next session"));
4308
- } catch (error) {
4309
- throw new Error(`Failed to remove data directory: ${error instanceof Error ? error.message : "Unknown error"}`);
4310
- }
4311
- } else {
4312
- console.log(chalk.blue("Operation cancelled"));
4313
- }
4314
- }
4315
5164
  async function handleNotifyCommand(args) {
4316
5165
  let message = "";
4317
5166
  let title = "";