miki-moni 0.3.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/README.zh-CN.md +275 -0
  4. package/README.zh-TW.md +275 -0
  5. package/bin/miki.mjs +49 -0
  6. package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
  7. package/dist/web/assets/index--89DkyV1.css +1 -0
  8. package/dist/web/assets/index-CyPlxvOn.js +64 -0
  9. package/dist/web/index.html +20 -0
  10. package/dist/web/pair-info.html +138 -0
  11. package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
  12. package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
  13. package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
  14. package/dist/web-phone/index.html +20 -0
  15. package/hooks/miki-emit.ps1 +56 -0
  16. package/package.json +89 -0
  17. package/shared/i18n.ts +915 -0
  18. package/src/cli/i18n-cli.ts +149 -0
  19. package/src/cli/miki.ts +168 -0
  20. package/src/cli/pair.ts +534 -0
  21. package/src/cli/prompt.ts +6 -0
  22. package/src/cli/pushable-iter.ts +45 -0
  23. package/src/cli/setup-self-host.ts +292 -0
  24. package/src/cli/setup-wizard.ts +130 -0
  25. package/src/cli/wrap.ts +742 -0
  26. package/src/config.ts +121 -0
  27. package/src/crypto.ts +66 -0
  28. package/src/data-dir.ts +31 -0
  29. package/src/ext-registry.ts +47 -0
  30. package/src/hook-handler.ts +86 -0
  31. package/src/index.ts +279 -0
  32. package/src/install-hooks.ts +107 -0
  33. package/src/notifier.ts +21 -0
  34. package/src/pairing.ts +100 -0
  35. package/src/protocol-ext.ts +46 -0
  36. package/src/relay-client.ts +468 -0
  37. package/src/relay-protocol.ts +57 -0
  38. package/src/server.ts +1134 -0
  39. package/src/session-resolver.ts +437 -0
  40. package/src/session-store.ts +131 -0
  41. package/src/types.ts +33 -0
  42. package/src/vscode-bridge.ts +407 -0
  43. package/src/wrap-process.ts +183 -0
  44. package/tools/tray.ps1 +286 -0
  45. package/worker/package.json +24 -0
  46. package/worker/src/daemon-relay.ts +348 -0
  47. package/worker/src/env.ts +11 -0
  48. package/worker/src/handshake.ts +63 -0
  49. package/worker/src/index.ts +81 -0
  50. package/worker/src/pairing-code.ts +39 -0
  51. package/worker/src/pairing-coordinator.ts +145 -0
  52. package/worker/wrangler-selfhost.toml +36 -0
  53. package/worker/wrangler.toml +29 -0
package/src/config.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { generateKeypair, generateSigningKeypair, toBase64 } from "./crypto.js";
5
+
6
+ export interface PairedPeer {
7
+ peer_id: string;
8
+ peer_name: string;
9
+ peer_pubkey: string; // X25519 encryption pubkey (base64) — for shared secret
10
+ peer_sign_pubkey?: string; // Ed25519 signing pubkey (base64) — keys the relay's
11
+ // paired_phones map; required for revoke_phone RPC
12
+ shared_secret: string; // base64 (32 bytes)
13
+ paired_at: number;
14
+ last_seen_at: number | null;
15
+ }
16
+
17
+ export interface RemoteEndpoint {
18
+ worker_url: string; // wss://...
19
+ /** Where the QR points. Defaults to the hosted PWA (https://miki-moni.pages.dev/);
20
+ * self-hosters get their own *.pages.dev written here by the setup wizard. */
21
+ phone_pwa_url?: string;
22
+ /** Persistent pairing token. Once set, the daemon registers this same token
23
+ * with the relay coordinator every restart, so the QR is permanent until
24
+ * the user explicitly rotates it with `pnpm pair --rotate`. 16-char
25
+ * Crockford base32. Optional for back-compat with old configs and for users
26
+ * who prefer ephemeral pair tokens via `pnpm pair --new`. */
27
+ pair_token?: string;
28
+ }
29
+
30
+ /** UI language for setup wizard, CLI banner, dashboard. Persisted across runs. */
31
+ export type Locale = "en" | "zh-CN" | "zh-TW";
32
+
33
+ export interface Config {
34
+ device: {
35
+ name: string;
36
+ pubkey: string; // X25519 box pub, base64
37
+ privkey: string; // X25519 box priv, base64
38
+ signing_pubkey: string; // Ed25519 sign pub, base64
39
+ signing_privkey: string; // Ed25519 sign priv (64B), base64
40
+ created_at: number;
41
+ };
42
+ /** Preferred UI language. Set by the setup wizard's first step; can be
43
+ * overridden anytime by editing config.json or via a future `miki locale`
44
+ * command. Defaults to "en" if missing (e.g. older configs). */
45
+ locale?: Locale;
46
+ remote?: RemoteEndpoint;
47
+ paired_peers: PairedPeer[];
48
+ }
49
+
50
+ function defaultDeviceName(): string {
51
+ return os.hostname() || `device-${Date.now()}`;
52
+ }
53
+
54
+ function makeDefaultConfig(): Config {
55
+ const box = generateKeypair();
56
+ const sign = generateSigningKeypair();
57
+ return {
58
+ device: {
59
+ name: defaultDeviceName(),
60
+ pubkey: toBase64(box.pubkey),
61
+ privkey: toBase64(box.privkey),
62
+ signing_pubkey: toBase64(sign.pubkey),
63
+ signing_privkey: toBase64(sign.privkey),
64
+ created_at: Date.now(),
65
+ },
66
+ paired_peers: [],
67
+ };
68
+ }
69
+
70
+ /** Load config; migrate older configs lacking signing keys; return final. */
71
+ export async function loadOrInitConfig(filePath: string): Promise<Config> {
72
+ let raw: string;
73
+ try {
74
+ raw = await fs.readFile(filePath, "utf8");
75
+ } catch (err: unknown) {
76
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
77
+ const cfg = makeDefaultConfig();
78
+ await saveConfig(filePath, cfg);
79
+ return cfg;
80
+ }
81
+ throw err;
82
+ }
83
+ let parsed: Config;
84
+ try {
85
+ parsed = JSON.parse(raw) as Config;
86
+ } catch (err) {
87
+ throw new Error(`Failed to parse config at ${filePath}: ${(err as Error).message}`);
88
+ }
89
+ // Migrate: add signing keys if missing
90
+ if (!parsed.device.signing_pubkey || !parsed.device.signing_privkey) {
91
+ const sign = generateSigningKeypair();
92
+ parsed.device.signing_pubkey = toBase64(sign.pubkey);
93
+ parsed.device.signing_privkey = toBase64(sign.privkey);
94
+ await saveConfig(filePath, parsed);
95
+ }
96
+ // Migrate: remove deprecated x_daemon_auth_token from remote if present
97
+ if (parsed.remote && (parsed.remote as any).x_daemon_auth_token) {
98
+ delete (parsed.remote as any).x_daemon_auth_token;
99
+ await saveConfig(filePath, parsed);
100
+ }
101
+ return parsed;
102
+ }
103
+
104
+ export async function saveConfig(filePath: string, cfg: Config): Promise<void> {
105
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
106
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
107
+ await fs.writeFile(tmp, JSON.stringify(cfg, null, 2));
108
+ await fs.rename(tmp, filePath);
109
+ }
110
+
111
+ export function addPairedPeer(cfg: Config, peer: PairedPeer): Config {
112
+ return { ...cfg, paired_peers: [...cfg.paired_peers, peer] };
113
+ }
114
+
115
+ export function removePairedPeer(cfg: Config, peerId: string): Config {
116
+ return { ...cfg, paired_peers: cfg.paired_peers.filter((p) => p.peer_id !== peerId) };
117
+ }
118
+
119
+ export function findPeerById(cfg: Config, peerId: string): PairedPeer | null {
120
+ return cfg.paired_peers.find((p) => p.peer_id === peerId) ?? null;
121
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,66 @@
1
+ import nacl from "tweetnacl";
2
+ import naclUtil from "tweetnacl-util";
3
+
4
+ export interface Keypair {
5
+ pubkey: Uint8Array; // 32 bytes
6
+ privkey: Uint8Array; // 32 bytes
7
+ }
8
+
9
+ export function generateKeypair(): Keypair {
10
+ const kp = nacl.box.keyPair();
11
+ return { pubkey: kp.publicKey, privkey: kp.secretKey };
12
+ }
13
+
14
+ export function deriveSharedSecret(myPrivkey: Uint8Array, theirPubkey: Uint8Array): Uint8Array {
15
+ // X25519 ECDH; same output for both sides
16
+ return nacl.box.before(theirPubkey, myPrivkey);
17
+ }
18
+
19
+ export interface Encrypted {
20
+ ct: string; // base64
21
+ nonce: string; // base64 (24 bytes)
22
+ }
23
+
24
+ export function encrypt(plaintext: string, sharedSecret: Uint8Array): Encrypted {
25
+ const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
26
+ const ptBytes = naclUtil.decodeUTF8(plaintext);
27
+ const ctBytes = nacl.secretbox(ptBytes, nonce, sharedSecret);
28
+ return { ct: toBase64(ctBytes), nonce: toBase64(nonce) };
29
+ }
30
+
31
+ export function decrypt(ct: string, nonce: string, sharedSecret: Uint8Array): string | null {
32
+ const ctBytes = fromBase64(ct);
33
+ const nonceBytes = fromBase64(nonce);
34
+ if (nonceBytes.length !== nacl.secretbox.nonceLength) return null;
35
+ const ptBytes = nacl.secretbox.open(ctBytes, nonceBytes, sharedSecret);
36
+ if (!ptBytes) return null;
37
+ return naclUtil.encodeUTF8(ptBytes);
38
+ }
39
+
40
+ export function toBase64(bytes: Uint8Array): string {
41
+ return naclUtil.encodeBase64(bytes);
42
+ }
43
+
44
+ export function fromBase64(s: string): Uint8Array {
45
+ return naclUtil.decodeBase64(s);
46
+ }
47
+
48
+ // ── Ed25519 signing (separate keypair from X25519 encryption keypair) ──────
49
+
50
+ export interface SigningKeypair {
51
+ pubkey: Uint8Array; // 32 bytes
52
+ privkey: Uint8Array; // 64 bytes (nacl.sign secret = priv ++ pub)
53
+ }
54
+
55
+ export function generateSigningKeypair(): SigningKeypair {
56
+ const kp = nacl.sign.keyPair();
57
+ return { pubkey: kp.publicKey, privkey: kp.secretKey };
58
+ }
59
+
60
+ export function sign(message: Uint8Array, privkey: Uint8Array): Uint8Array {
61
+ return nacl.sign.detached(message, privkey);
62
+ }
63
+
64
+ export function verify(message: Uint8Array, sig: Uint8Array, pubkey: Uint8Array): boolean {
65
+ return nacl.sign.detached.verify(message, sig, pubkey);
66
+ }
@@ -0,0 +1,31 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import { promises as fs } from "node:fs";
4
+
5
+ // Central runtime-data location for the Miki-Moni daemon.
6
+ // Single source of truth — every component derives port file, config, sqlite,
7
+ // log file from here.
8
+ export const HUB_HOME = path.join(os.homedir(), ".miki-moni");
9
+ export const PORT_FILE = path.join(HUB_HOME, "port");
10
+ export const CONFIG_FILE = path.join(HUB_HOME, "config.json");
11
+ export const DB_FILE = path.join(HUB_HOME, "state.db");
12
+ export const LOG_FILE = path.join(HUB_HOME, "miki-moni.log");
13
+
14
+ // One-shot migration from the legacy `~/.cc-hub` directory that pre-2026-05-17
15
+ // installs used. If the legacy dir exists and the new one doesn't, rename
16
+ // (preserves pairing keys, sessions DB, port file). Safe to call repeatedly —
17
+ // after the first run the legacy dir is gone and this is a no-op.
18
+ //
19
+ // Must be invoked before any code reads or writes inside HUB_HOME.
20
+ export async function migrateLegacyHubHome(): Promise<{ migrated: boolean }> {
21
+ const legacy = path.join(os.homedir(), ".cc-hub");
22
+ const [legacyExists, currentExists] = await Promise.all([
23
+ fs.access(legacy).then(() => true, () => false),
24
+ fs.access(HUB_HOME).then(() => true, () => false),
25
+ ]);
26
+ if (legacyExists && !currentExists) {
27
+ await fs.rename(legacy, HUB_HOME);
28
+ return { migrated: true };
29
+ }
30
+ return { migrated: false };
31
+ }
@@ -0,0 +1,47 @@
1
+ import type { WebSocket } from "ws";
2
+ import { normalizePath } from "./protocol-ext.js";
3
+
4
+ export interface ExtInfo {
5
+ workspace_root: string; // will be re-normalized inside add() — caller can pass raw
6
+ version: string;
7
+ registered_at: number;
8
+ }
9
+
10
+ interface InternalEntry {
11
+ ws: WebSocket | any; // `any` to keep tests trivially mockable
12
+ info: ExtInfo; // info.workspace_root is normalized
13
+ }
14
+
15
+ export class ExtRegistry {
16
+ private entries: InternalEntry[] = [];
17
+
18
+ add(ws: WebSocket | any, info: ExtInfo): void {
19
+ const normalized: ExtInfo = { ...info, workspace_root: normalizePath(info.workspace_root) };
20
+ // Replace existing entry for same ws (defensive — re-register on reconnect)
21
+ this.entries = this.entries.filter((e) => e.ws !== ws);
22
+ this.entries.push({ ws, info: normalized });
23
+ }
24
+
25
+ remove(ws: WebSocket | any): void {
26
+ this.entries = this.entries.filter((e) => e.ws !== ws);
27
+ }
28
+
29
+ findForCwd(cwd: string): WebSocket | null {
30
+ const target = normalizePath(cwd);
31
+ const matches = this.entries
32
+ .filter((e) => isAncestor(e.info.workspace_root, target))
33
+ .sort((a, b) => b.info.workspace_root.length - a.info.workspace_root.length);
34
+ return matches[0]?.ws ?? null;
35
+ }
36
+
37
+ list(): Array<{ info: ExtInfo }> {
38
+ return this.entries.map((e) => ({ info: e.info }));
39
+ }
40
+ }
41
+
42
+ // True when `cwd` equals `root` OR is a path-descendant of it.
43
+ // Critically guards against false prefix matches: "d:/codex" must NOT match "d:/code".
44
+ function isAncestor(root: string, cwd: string): boolean {
45
+ if (root === cwd) return true;
46
+ return cwd.startsWith(root + "/");
47
+ }
@@ -0,0 +1,86 @@
1
+ import path from "node:path";
2
+ import type { HookEvent, Session, SessionStatus } from "./types.js";
3
+ import type { SessionStore } from "./session-store.js";
4
+ import type { SessionResolver } from "./session-resolver.js";
5
+ import type { Notifier } from "./notifier.js";
6
+
7
+ const STATUS_BY_EVENT: Record<HookEvent["event_type"], SessionStatus> = {
8
+ session_start: "active",
9
+ user_prompt: "active",
10
+ pre_tool_use: "active",
11
+ post_tool_use: "active",
12
+ stop: "waiting",
13
+ };
14
+
15
+ function basename(cwd: string): string {
16
+ const normalized = cwd.replace(/\\/g, "/");
17
+ return path.posix.basename(normalized);
18
+ }
19
+
20
+ // On Windows, paths like "D:\code\x" and "d:/code/x" point to the same place.
21
+ // Normalize: lowercase the drive letter and use backslashes consistently.
22
+ export function normalizeCwd(cwd: string): string {
23
+ let n = cwd.replace(/\//g, "\\");
24
+ // Lowercase Windows drive letter (e.g. "D:\..." → "d:\...")
25
+ if (/^[A-Za-z]:/.test(n)) n = n[0]!.toLowerCase() + n.slice(1);
26
+ // Trim trailing backslash unless it's just a drive root like "c:\"
27
+ if (n.length > 3 && n.endsWith("\\")) n = n.replace(/\\+$/, "");
28
+ return n;
29
+ }
30
+
31
+ export class HookHandler {
32
+ constructor(
33
+ private store: SessionStore,
34
+ private resolver: SessionResolver,
35
+ private notifier?: Notifier,
36
+ ) {}
37
+
38
+ async handle(ev: HookEvent): Promise<void> {
39
+ const cwd = normalizeCwd(ev.cwd);
40
+
41
+ // session_uuid is the primary identity. Hooks should always provide it
42
+ // (Claude Code sends session_id in every hook event per the official docs).
43
+ // If absent, try resolver as a last resort; otherwise drop the event silently.
44
+ let sessionUuid = ev.session_uuid;
45
+ if (!sessionUuid) {
46
+ sessionUuid = await this.resolver.resolveLatest(cwd);
47
+ if (!sessionUuid) return; // cannot insert without a primary key
48
+ }
49
+
50
+ const existing = this.store.get(sessionUuid);
51
+ if (existing && existing.last_event_at > ev.timestamp) return; // last-write-wins
52
+
53
+ // cwd is IMMUTABLE once the row exists. Claude Code's projects-dir
54
+ // encoding is derived from the cwd-at-session-start; the SDK's
55
+ // `resume: uuid` only works if you pass that same cwd. If we let later
56
+ // hook events overwrite (e.g. the agent cd'd into a subfolder, or a
57
+ // tool runs in a different working dir), /wrap/start later uses the
58
+ // wrong cwd → SDK throws "No conversation found with session ID".
59
+ // The very first event for a uuid sets cwd; every subsequent event
60
+ // keeps it. Bug repro: agent cd'd from d:\code into d:\code\cc-hub →
61
+ // hook events flipped DB.cwd → wrap-spawn looked in d--code-cc-hub/
62
+ // for a transcript that actually lived in d--code/.
63
+ const cwdToStore = existing?.cwd ?? cwd;
64
+ const next: Session = {
65
+ cwd: cwdToStore,
66
+ session_uuid: sessionUuid,
67
+ project_name: existing?.project_name ?? basename(cwdToStore),
68
+ status: STATUS_BY_EVENT[ev.event_type],
69
+ last_event_at: ev.timestamp,
70
+ last_message_preview: existing?.last_message_preview ?? "",
71
+ tokens_in: existing?.tokens_in ?? 0,
72
+ tokens_out: existing?.tokens_out ?? 0,
73
+ vscode_pid: existing?.vscode_pid ?? null,
74
+ };
75
+ this.store.upsert(next);
76
+
77
+ const wasWaiting = existing?.status === "waiting";
78
+ const isWaiting = next.status === "waiting";
79
+ if (this.notifier && isWaiting && !wasWaiting) {
80
+ void this.notifier.notify({
81
+ project: next.project_name,
82
+ message: "Claude is waiting for you",
83
+ });
84
+ }
85
+ }
86
+ }
package/src/index.ts ADDED
@@ -0,0 +1,279 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import http from "node:http";
4
+ import { promises as fs, appendFileSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawn } from "node:child_process";
7
+ import pino from "pino";
8
+ import { createApp } from "./server.js";
9
+ import { SessionStore } from "./session-store.js";
10
+ import { SessionResolver } from "./session-resolver.js";
11
+ import { HookHandler } from "./hook-handler.js";
12
+ import { VscodeBridge } from "./vscode-bridge.js";
13
+ import { Notifier } from "./notifier.js";
14
+ import { loadOrInitConfig } from "./config.js";
15
+ import { RelayClient } from "./relay-client.js";
16
+ import { HUB_HOME, PORT_FILE, DB_FILE, CONFIG_FILE, LOG_FILE, migrateLegacyHubHome } from "./data-dir.js";
17
+ import { killOrphans } from "./wrap-process.js";
18
+
19
+ const PROJECTS_ROOT = path.join(os.homedir(), ".claude", "projects");
20
+ const DEFAULT_PORT = 8765;
21
+
22
+ async function findFreePort(start: number, maxTries = 10): Promise<number> {
23
+ const net = await import("node:net");
24
+ for (let i = 0; i < maxTries; i++) {
25
+ const port = start + i;
26
+ const free = await new Promise<boolean>((resolve) => {
27
+ const srv = net.createServer();
28
+ srv.once("error", () => resolve(false));
29
+ srv.once("listening", () => srv.close(() => resolve(true)));
30
+ srv.listen(port, "127.0.0.1");
31
+ });
32
+ if (free) return port;
33
+ }
34
+ throw new Error(`no free port in [${start}, ${start + maxTries})`);
35
+ }
36
+
37
+ // HTTP-probe a daemon on the given port. Returns true if it answers /sessions
38
+ // within 800ms. Used as the singleton guard so a second `pnpm start` (or
39
+ // wrap's auto-spawn) doesn't fork a duplicate daemon on a different port that
40
+ // would race over PORT_FILE.
41
+ async function pingDaemon(port: number): Promise<boolean> {
42
+ return new Promise((resolve) => {
43
+ const req = http.get(`http://127.0.0.1:${port}/sessions`, { timeout: 800 }, (res) => {
44
+ res.resume();
45
+ resolve((res.statusCode ?? 0) > 0);
46
+ });
47
+ req.on("error", () => resolve(false));
48
+ req.on("timeout", () => { req.destroy(); resolve(false); });
49
+ });
50
+ }
51
+
52
+ async function readPortFile(): Promise<number | null> {
53
+ try {
54
+ const raw = await fs.readFile(PORT_FILE, "utf8");
55
+ const n = parseInt(raw.trim(), 10);
56
+ return Number.isFinite(n) ? n : null;
57
+ } catch { return null; }
58
+ }
59
+
60
+ async function main(): Promise<void> {
61
+ // Move legacy ~/.cc-hub into ~/.miki-moni on first boot after rename.
62
+ // Idempotent: no-op once new dir exists.
63
+ const mig = await migrateLegacyHubHome();
64
+ if (mig.migrated) {
65
+ console.log("migrated ~/.cc-hub → ~/.miki-moni (legacy install detected)");
66
+ }
67
+ await fs.mkdir(HUB_HOME, { recursive: true });
68
+
69
+ // Multi-stream: file gets the full debug trace for post-hoc support; stdout
70
+ // is quiet by default ("warn") so a normal end-user only sees genuine
71
+ // problems. Power users override with MIKI_LOG_LEVEL=info / debug etc.
72
+ const fileStream = (await import("node:fs")).createWriteStream(LOG_FILE, { flags: "a" });
73
+ const log = pino(
74
+ { level: "debug" },
75
+ pino.multistream([
76
+ { stream: process.stdout, level: process.env.MIKI_LOG_LEVEL ?? "warn" },
77
+ { stream: fileStream, level: "debug" },
78
+ ]),
79
+ );
80
+
81
+ // Singleton guard. PORT_FILE is shared global state; if another daemon is
82
+ // already alive, claiming a different port and overwriting PORT_FILE causes
83
+ // the dashboard and CLIs to split-brain across two daemons. Probe the
84
+ // currently-recorded port first, then the canonical default, then bail.
85
+ //
86
+ // The "two-daemons" race used to surface like this:
87
+ // 1. Daemon A runs on 8765, PORT_FILE=8765
88
+ // 2. A crashes; PORT_FILE still points at 8765 but no one's home
89
+ // 3. `miki claude` wrap reads 8765, ping fails, autostarts Daemon B
90
+ // 4. B's findFreePort skips 8765 (TIME_WAIT) → binds 8766 → writes
91
+ // PORT_FILE=8766
92
+ // 5. A's old socket clears, user runs `pnpm start` again → Daemon C binds
93
+ // 8765 → writes PORT_FILE=8765 → race over PORT_FILE
94
+ // Refusing to start when a live daemon is detected eliminates the race
95
+ // entirely. Set MIKI_FORCE_RESTART=1 to skip the guard (e.g. for debugging).
96
+ if (!process.env.MIKI_FORCE_RESTART) {
97
+ for (const candidate of [await readPortFile(), DEFAULT_PORT]) {
98
+ if (candidate && await pingDaemon(candidate)) {
99
+ console.log(`miki-moni daemon already running on http://127.0.0.1:${candidate} — exiting (set MIKI_FORCE_RESTART=1 to override)`);
100
+ // Reconcile PORT_FILE in case it drifted (e.g. step 4 above): if the
101
+ // live port doesn't match PORT_FILE, fix it so the next CLI finds
102
+ // home on the first try.
103
+ const recorded = await readPortFile();
104
+ if (recorded !== candidate) {
105
+ await fs.writeFile(PORT_FILE, String(candidate));
106
+ log.info({ from: recorded, to: candidate }, "reconciled stale PORT_FILE");
107
+ }
108
+ process.exit(0);
109
+ }
110
+ }
111
+ }
112
+
113
+ const port = await findFreePort(DEFAULT_PORT);
114
+ await fs.writeFile(PORT_FILE, String(port));
115
+
116
+ const store = new SessionStore(DB_FILE);
117
+ // On daemon startup we don't know what's still alive. Mark everything stale
118
+ // (but DON'T delete) — dashboard can filter them out; hook events + wrap
119
+ // reconnects will upgrade the row back to "active" on next signal. This is
120
+ // safer than truncate(), which lost the row entirely and prevented /send
121
+ // routing for wrap sessions until they fired another hook.
122
+ const staled = store.markAllStale();
123
+ log.info({ staled }, "marked all sessions stale on startup");
124
+
125
+ // Opt-in orphan sweep. The `taskkill /T /F` in killProcessTree turned out
126
+ // to bring down the *current* daemon when the matched orphan happened to
127
+ // share a Windows job object with us (reproducible inside Claude Code's
128
+ // bash sandbox + likely the user's terminal too — daemon would die ~1.5s
129
+ // after listen with no exception, no signal, just TerminateProcess).
130
+ // Until killOrphans uses a safer mechanism (skip /T, or verify parent is
131
+ // dead before killing), it stays off by default. Wraps left over from a
132
+ // crashed daemon session just sit there — annoying but not fatal.
133
+ if (process.env.MIKI_KILL_ORPHANS === "1") {
134
+ killOrphans(log).then((n) => {
135
+ if (n > 0) log.info({ killed: n }, "swept orphan `miki claude` processes from previous daemon");
136
+ }).catch((err) => log.warn({ error: String(err) }, "orphan sweep failed (non-fatal)"));
137
+ }
138
+ const resolver = new SessionResolver(PROJECTS_ROOT);
139
+ const notifier = new Notifier();
140
+ const handler = new HookHandler(store, resolver, notifier);
141
+ const bridge = new VscodeBridge();
142
+ // Resolve dist/web relative to this source file. After a global npm install
143
+ // the daemon's process.cwd() is the user's calling dir, not the package
144
+ // root, so path.resolve("dist/web") would point at the wrong place.
145
+ const _moduleDir = path.dirname(fileURLToPath(import.meta.url));
146
+ const webDir = path.resolve(_moduleDir, "..", "dist", "web");
147
+
148
+ const { app, server } = createApp({ store, handler, bridge, notifier, webDir, log });
149
+
150
+ // Serve web UI if built
151
+ const express = (await import("express")).default;
152
+ app.use(express.static(webDir, { fallthrough: true }));
153
+
154
+ // Phase 2: optional remote relay to user's Cloudflare Worker
155
+ const config = await loadOrInitConfig(CONFIG_FILE);
156
+ let relay: RelayClient | null = null;
157
+ if (config.remote && config.paired_peers.length > 0) {
158
+ relay = new RelayClient({ config, store, bridge });
159
+ await relay.start();
160
+ log.info({ worker_url: config.remote.worker_url, peers: config.paired_peers.length }, "relay started");
161
+ console.log(`relay -> ${config.remote.worker_url} (${config.paired_peers.length} peer${config.paired_peers.length === 1 ? "" : "s"})`);
162
+ } else {
163
+ log.info("relay disabled (no remote configured or no paired peers)");
164
+ }
165
+
166
+ server.listen(port, "127.0.0.1", () => {
167
+ log.info({ port }, "miki-moni listening");
168
+ console.log(`miki-moni listening on http://127.0.0.1:${port}`);
169
+ // Windows-only: spawn the sleeping-cat tray icon so the user has a
170
+ // visible reminder the daemon is alive. Detached + unref'd so it owns
171
+ // its own lifetime; the script watches our PID and self-exits when we
172
+ // die. Failures here are non-fatal — the daemon is fully usable
173
+ // without the tray icon.
174
+ // Skip if MIKI_NO_TRAY_SPAWN=1 — set by /admin/restart / /admin/rotate-pair
175
+ // so respawned daemons don't duplicate the cat icon (the previous tray
176
+ // follows us via /admin/pid).
177
+ if (process.platform === "win32" && process.env.MIKI_NO_TRAY_SPAWN !== "1") {
178
+ spawnTrayHelper(port, log).catch((err) => {
179
+ log.warn({ err: String(err) }, "tray helper spawn failed (non-fatal)");
180
+ });
181
+ }
182
+ });
183
+
184
+ const shutdown = async () => {
185
+ log.info("shutting down");
186
+ if (relay) { try { await relay.stop(); } catch { /* ignore */ } }
187
+ server.close(() => { store.close(); process.exit(0); });
188
+ };
189
+ process.on("SIGINT", shutdown);
190
+ process.on("SIGTERM", shutdown);
191
+ }
192
+
193
+ // Spawn tools/tray.ps1 as a detached child. The script renders a tray icon
194
+ // with right-click menu (Open / Restart / Quit) and self-exits when our PID
195
+ // disappears. Resolved relative to this file so it works whether the daemon
196
+ // was launched from the repo or via a packaged `miki` global install.
197
+ async function spawnTrayHelper(port: number, log: pino.Logger): Promise<void> {
198
+ const here = path.dirname(fileURLToPath(import.meta.url));
199
+ // src/index.ts compiled lives one level under repo root; tools/tray.ps1
200
+ // lives at the repo root. Two candidate paths so this works both in
201
+ // tsx-direct and in a built dist/ layout.
202
+ const candidates = [
203
+ path.resolve(here, "..", "tools", "tray.ps1"),
204
+ path.resolve(here, "..", "..", "tools", "tray.ps1"),
205
+ ];
206
+ let scriptPath: string | null = null;
207
+ for (const p of candidates) {
208
+ try { await fs.access(p); scriptPath = p; break; } catch { /* try next */ }
209
+ }
210
+ if (!scriptPath) {
211
+ log.warn({ candidates }, "tray.ps1 not found — skipping tray icon");
212
+ return;
213
+ }
214
+ // Two Windows-spawn quirks fixed here:
215
+ //
216
+ // 1. PowerShell's argv parser mangles Windows-style backslash paths when
217
+ // they come through Node's CreateProcess wrapping ("D:\code\..."
218
+ // became "D:codec..."). Forward slashes work natively on every
219
+ // Windows tool and don't get eaten by escape processing.
220
+ //
221
+ // 2. STA is required for WinForms NotifyIcon (default MTA caused silent
222
+ // exit on Application.Run when spawned from Node's CreateProcess
223
+ // DETACHED_PROCESS flag).
224
+ //
225
+ // 3. Going through `cmd /c start /B` instead of spawning powershell.exe
226
+ // directly side-steps a subtler Node-on-Windows issue where the
227
+ // detached child still inherits CreateProcess flags that prevent
228
+ // PowerShell from running a WinForms message pump. Direct spawn died
229
+ // silently within ~1s; `Start-Process` from PowerShell worked fine,
230
+ // and so does the cmd /c start indirection — both yield a process
231
+ // with the flags Application.Run() expects.
232
+ const scriptForPs = scriptPath.replace(/\\/g, "/");
233
+ // -ExecutionPolicy Bypass: many users have the default `Restricted` policy
234
+ // which silently refuses to run .ps1 files. Bypass only applies to this one
235
+ // process invocation (we don't touch their system-wide policy).
236
+ // Capture stdout/stderr to the daemon log so silent crashes are debuggable.
237
+ const trayLog = path.join(path.dirname(LOG_FILE), "tray.log");
238
+ const child = spawn(
239
+ "cmd.exe",
240
+ [
241
+ "/c", "start", "/B", "",
242
+ "powershell.exe",
243
+ "-NoProfile", "-STA",
244
+ "-ExecutionPolicy", "Bypass",
245
+ "-WindowStyle", "Hidden",
246
+ "-File", scriptForPs,
247
+ "-DaemonPid", String(process.pid),
248
+ "-Port", String(port),
249
+ ],
250
+ {
251
+ detached: true,
252
+ stdio: ["ignore", "ignore", "ignore"],
253
+ windowsHide: true,
254
+ },
255
+ );
256
+ child.on("error", (err) => log.warn({ err: String(err) }, "tray helper error"));
257
+ child.unref();
258
+ log.info({ scriptPath, pid: child.pid, trayLog }, "tray helper spawned");
259
+ }
260
+
261
+ // Catch silent crashes (Node's default for uncaughtException is to exit
262
+ // with no error message). Write to LOG_FILE directly because pino's stream
263
+ // may not flush before exit. ONLY catch — do NOT subscribe to beforeExit /
264
+ // exit, which fire on every normal Node tick when the loop drains and
265
+ // produce noise that looks like crashes.
266
+ function logFatal(kind: string, err: unknown): void {
267
+ try {
268
+ const line = JSON.stringify({ level: 60, time: Date.now(), pid: process.pid, kind, err: String(err), stack: (err as Error)?.stack }) + "\n";
269
+ appendFileSync(LOG_FILE, line);
270
+ process.stderr.write(line);
271
+ } catch { /* nothing we can do */ }
272
+ }
273
+ process.on("uncaughtException", (err) => { logFatal("uncaughtException", err); process.exit(1); });
274
+ process.on("unhandledRejection", (reason) => { logFatal("unhandledRejection", reason); });
275
+
276
+ main().catch((err) => {
277
+ console.error(err);
278
+ process.exit(1);
279
+ });