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.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/README.zh-CN.md +275 -0
- package/README.zh-TW.md +275 -0
- package/bin/miki.mjs +49 -0
- package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
- package/dist/web/assets/index--89DkyV1.css +1 -0
- package/dist/web/assets/index-CyPlxvOn.js +64 -0
- package/dist/web/index.html +20 -0
- package/dist/web/pair-info.html +138 -0
- package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
- package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
- package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
- package/dist/web-phone/index.html +20 -0
- package/hooks/miki-emit.ps1 +56 -0
- package/package.json +89 -0
- package/shared/i18n.ts +915 -0
- package/src/cli/i18n-cli.ts +149 -0
- package/src/cli/miki.ts +168 -0
- package/src/cli/pair.ts +534 -0
- package/src/cli/prompt.ts +6 -0
- package/src/cli/pushable-iter.ts +45 -0
- package/src/cli/setup-self-host.ts +292 -0
- package/src/cli/setup-wizard.ts +130 -0
- package/src/cli/wrap.ts +742 -0
- package/src/config.ts +121 -0
- package/src/crypto.ts +66 -0
- package/src/data-dir.ts +31 -0
- package/src/ext-registry.ts +47 -0
- package/src/hook-handler.ts +86 -0
- package/src/index.ts +279 -0
- package/src/install-hooks.ts +107 -0
- package/src/notifier.ts +21 -0
- package/src/pairing.ts +100 -0
- package/src/protocol-ext.ts +46 -0
- package/src/relay-client.ts +468 -0
- package/src/relay-protocol.ts +57 -0
- package/src/server.ts +1134 -0
- package/src/session-resolver.ts +437 -0
- package/src/session-store.ts +131 -0
- package/src/types.ts +33 -0
- package/src/vscode-bridge.ts +407 -0
- package/src/wrap-process.ts +183 -0
- package/tools/tray.ps1 +286 -0
- package/worker/package.json +24 -0
- package/worker/src/daemon-relay.ts +348 -0
- package/worker/src/env.ts +11 -0
- package/worker/src/handshake.ts +63 -0
- package/worker/src/index.ts +81 -0
- package/worker/src/pairing-code.ts +39 -0
- package/worker/src/pairing-coordinator.ts +145 -0
- package/worker/wrangler-selfhost.toml +36 -0
- 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
|
+
}
|
package/src/data-dir.ts
ADDED
|
@@ -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
|
+
});
|