friend-assistant 0.1.1 → 0.1.3

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/friend.mjs CHANGED
@@ -1269,8 +1269,8 @@ function repairDangling(db) {
1269
1269
  }
1270
1270
  function loadHistory(db) {
1271
1271
  const msgs = loadAll(db, WINDOW);
1272
- const start = msgs.findIndex((m) => m.role === "user" && m.content.some((b) => b.type === "text"));
1273
- return start === -1 ? [] : msgs.slice(start);
1272
+ const start2 = msgs.findIndex((m) => m.role === "user" && m.content.some((b) => b.type === "text"));
1273
+ return start2 === -1 ? [] : msgs.slice(start2);
1274
1274
  }
1275
1275
 
1276
1276
  // ../server/src/agent/prompt.ts
@@ -2111,6 +2111,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2111
2111
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2112
2112
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2113
2113
  import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
2114
+ import crossSpawn from "cross-spawn";
2114
2115
  import { createHash } from "node:crypto";
2115
2116
 
2116
2117
  // ../server/src/connectors/store.ts
@@ -2235,6 +2236,20 @@ function friendlyToolName(original, taken) {
2235
2236
  taken.add(out);
2236
2237
  return out;
2237
2238
  }
2239
+ function killTree(pid) {
2240
+ try {
2241
+ if (process.platform === "win32") crossSpawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {
2242
+ });
2243
+ else process.kill(pid, "SIGKILL");
2244
+ } catch {
2245
+ }
2246
+ }
2247
+ async function stopLive(l) {
2248
+ const pid = l.transport?.pid ?? null;
2249
+ await Promise.race([l.client.close().catch(() => {
2250
+ }), new Promise((r) => setTimeout(r, 3e3).unref?.())]);
2251
+ if (pid) killTree(pid);
2252
+ }
2238
2253
  var messageOf = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 400);
2239
2254
  var sleepReject = (ms, what) => new Promise((_, rej) => {
2240
2255
  const t = setTimeout(() => rej(new Error(what)), ms);
@@ -2374,13 +2389,14 @@ ${l.stderr}` : ""}`, isError: true };
2374
2389
  entry.connecting = (async () => {
2375
2390
  try {
2376
2391
  const transport = this.transportFor(cfg, entry);
2392
+ entry.transport = transport;
2377
2393
  client.onclose = () => {
2378
2394
  if (this.live.get(id2) === entry && entry.status === "ready") {
2379
2395
  entry.status = "error";
2380
2396
  entry.error = "The connection closed.";
2381
2397
  }
2382
2398
  };
2383
- await Promise.race([client.connect(transport), sleepReject(this.opts.connectTimeoutMs ?? 3e4, "It did not answer in time.")]);
2399
+ await Promise.race([client.connect(transport), sleepReject(this.opts.connectTimeoutMs ?? 6e4, "It did not answer in time.")]);
2384
2400
  await this.readTools(id2, entry);
2385
2401
  client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
2386
2402
  try {
@@ -2393,10 +2409,7 @@ ${l.stderr}` : ""}`, isError: true };
2393
2409
  } catch (e) {
2394
2410
  entry.status = "error";
2395
2411
  entry.error = `${messageOf(e)}${entry.stderr ? ` (${entry.stderr.trim().split("\n").slice(-2).join(" ").slice(0, 200)})` : ""}`;
2396
- try {
2397
- await client.close();
2398
- } catch {
2399
- }
2412
+ await stopLive(entry);
2400
2413
  throw new Error(entry.error);
2401
2414
  } finally {
2402
2415
  entry.connecting = void 0;
@@ -2447,10 +2460,7 @@ ${l.stderr}` : ""}`, isError: true };
2447
2460
  this.forget(id2);
2448
2461
  if (!l) return;
2449
2462
  l.client.onclose = void 0;
2450
- try {
2451
- await l.client.close();
2452
- } catch {
2453
- }
2463
+ await stopLive(l);
2454
2464
  }
2455
2465
  };
2456
2466
  function flatten(content) {
@@ -2473,11 +2483,12 @@ var PRESETS = [
2473
2483
  id: "chrome",
2474
2484
  name: "Chrome control",
2475
2485
  description: "Lets Friend open pages, read them, click and type in your own Chrome, through your chrome-control extension.",
2476
- connector: { id: "chrome", name: "Chrome control", kind: "stdio", command: "chrome-control-mcp", args: [] },
2486
+ quick: true,
2487
+ connector: { id: "chrome", name: "Chrome control", kind: "stdio", command: "npx", args: ["-y", "chrome-control-mcp"] },
2477
2488
  setup: [
2478
- "Install it once: npm install -g chrome-control-mcp",
2479
- "Run chrome-control-mcp setup, then load the extension it points to in chrome://extensions (Developer mode, Load unpacked).",
2480
- "Switch the extension on in Chrome. Friend starts the program itself when you add the connector."
2489
+ "Press Connect. Friend checks that Chrome control is installed and, if it is, adds it for you: it runs npx -y chrome-control-mcp, the same as claude mcp add chrome-control -- npx -y chrome-control-mcp.",
2490
+ "If it is not installed yet, Friend says so and can install it for you in the background (npx -y chrome-control-mcp setup).",
2491
+ "The one step nobody can do for you, because Chrome does not allow silent installs: load the extension in chrome://extensions (Developer mode, Load unpacked) and switch it on."
2481
2492
  ],
2482
2493
  warning: "While the extension is on, Friend can act as you on any site it allows. Every action still asks for your approval; in the extension's popup you can limit it to certain sites."
2483
2494
  },
@@ -2496,6 +2507,122 @@ var PRESETS = [
2496
2507
  }
2497
2508
  ];
2498
2509
 
2510
+ // ../server/src/connectors/quick.ts
2511
+ import { existsSync as existsSync4 } from "node:fs";
2512
+ import { homedir } from "node:os";
2513
+ import { join as join6 } from "node:path";
2514
+ var find = (id2) => PRESETS.find((p) => p.id === id2 && p.quick);
2515
+ var isQuickPreset = (id2) => !!find(id2);
2516
+ var MANUAL_EXTENSION = "One step only you can do, because Chrome does not allow programs to install extensions silently: open chrome://extensions, turn on Developer mode (top right), click Load unpacked and choose the folder shown above. Then click the extension's icon and switch it on.";
2517
+ function chromeFolder(home) {
2518
+ return join6(home, ".chrome-control-mcp");
2519
+ }
2520
+ function chromeInstalled(home = homedir()) {
2521
+ const base = chromeFolder(home);
2522
+ const ext = join6(base, "extension");
2523
+ const installed = existsSync4(join6(base, "token")) && existsSync4(join6(ext, "manifest.json")) && existsSync4(join6(base, "native-host"));
2524
+ return { installed, extensionFolder: installed ? ext : null };
2525
+ }
2526
+ function quickCheck(id2, deps = {}) {
2527
+ const preset = find(id2);
2528
+ if (!preset) return null;
2529
+ const c = chromeInstalled(deps.home);
2530
+ if (c.installed) {
2531
+ return { preset: id2, name: preset.name, ready: true, message: "Chrome control is installed on this PC.", installCommand: null, manualStep: null, extensionFolder: c.extensionFolder };
2532
+ }
2533
+ return {
2534
+ preset: id2,
2535
+ name: preset.name,
2536
+ ready: false,
2537
+ message: "Chrome control is not installed on this PC yet. Please install it first, then press Connect again.",
2538
+ installCommand: "npx -y chrome-control-mcp setup",
2539
+ manualStep: MANUAL_EXTENSION,
2540
+ extensionFolder: null
2541
+ };
2542
+ }
2543
+ async function quickInstall(id2, deps, onLine) {
2544
+ if (!find(id2)) return { ok: false, output: "", extensionFolder: null, message: "That is not a one-click connector." };
2545
+ const lines = [];
2546
+ const r = await deps.run("npx", ["-y", "chrome-control-mcp", "setup", "--no-claude", "--no-open", "--no-flag"], {
2547
+ onLine: (l) => {
2548
+ lines.push(l);
2549
+ onLine?.(l);
2550
+ }
2551
+ });
2552
+ const after = chromeInstalled(deps.home);
2553
+ const output = (lines.length ? lines : r.stdout.split(/\r?\n/)).filter((l) => l.trim()).slice(-12).join("\n");
2554
+ if (r.code !== 0 || !after.installed) {
2555
+ return { ok: false, output, extensionFolder: null, message: "The install did not finish. You can run it yourself in a terminal: npx -y chrome-control-mcp setup" };
2556
+ }
2557
+ return { ok: true, output, extensionFolder: after.extensionFolder, message: `Chrome control is installed. ${MANUAL_EXTENSION}` };
2558
+ }
2559
+ function readExtensionState(text2) {
2560
+ try {
2561
+ const o = JSON.parse(text2);
2562
+ if (typeof o.extension === "boolean") return o.extension ? "connected" : "off";
2563
+ if (typeof o.connected === "boolean") return o.connected ? "connected" : "off";
2564
+ } catch {
2565
+ }
2566
+ const t = text2.toLowerCase();
2567
+ if (/not connected|disconnected|no extension|extension is (off|not)|waiting for (the )?extension|not enabled|disabled/.test(t)) return "off";
2568
+ if (/extension[^.\n]*connected|connected[^.\n]*extension|"connected"\s*:\s*true|bridge[^.\n]*connected/.test(t)) return "connected";
2569
+ return "unknown";
2570
+ }
2571
+ async function quickConnect(id2, deps) {
2572
+ const preset = find(id2);
2573
+ if (!preset) return { ok: false, connector: null, extension: "unknown", message: "That is not a one-click connector." };
2574
+ const check3 = quickCheck(id2, deps);
2575
+ if (!check3.ready) return { ok: false, connector: null, extension: "unknown", message: check3.message };
2576
+ const { hub } = deps;
2577
+ const existing = hub.list().find((c) => c.id === preset.connector.id);
2578
+ const view = existing ? existing.enabled ? await hub.reload(existing.id) : await hub.setEnabled(existing.id, true) : await hub.add(preset.connector);
2579
+ const connector = { id: view.id, name: view.name, status: view.status, error: view.error, tools: view.tools };
2580
+ if (view.status !== "ready") {
2581
+ return { ok: false, connector, extension: "unknown", message: `${preset.name} is saved, but it did not start: ${view.error ?? "unknown error"}` };
2582
+ }
2583
+ const statusTool = hub.toolSpecs().find((t) => t.name === `mcp.${view.id}.status`);
2584
+ let extension = "unknown";
2585
+ if (statusTool) {
2586
+ const r = await hub.call(statusTool.name, {});
2587
+ if (!r.isError) extension = readExtensionState(r.text);
2588
+ }
2589
+ const base = `${preset.name} is on with ${view.tools} tools.`;
2590
+ const message = extension === "connected" ? `${base} Your Chrome extension is connected. Friend can use Chrome now (it still asks before each action).` : extension === "off" ? `${base} But the Chrome extension is not connected yet. Click its icon in Chrome and switch it on (if it is not loaded: chrome://extensions, Developer mode, Load unpacked, then choose ${check3.extensionFolder}).` : `${base} Check that the Chrome extension is loaded and switched on (click its icon in Chrome).`;
2591
+ return { ok: true, connector, extension, message };
2592
+ }
2593
+
2594
+ // ../server/src/connectors/run.ts
2595
+ import crossSpawn2 from "cross-spawn";
2596
+ var LIMIT_MS = 5 * 6e4;
2597
+ var runCommand = (cmd, args, opts) => new Promise((resolve10) => {
2598
+ let stdout = "";
2599
+ let child;
2600
+ try {
2601
+ child = crossSpawn2(cmd, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
2602
+ } catch (e) {
2603
+ resolve10({ code: 127, stdout: e.message });
2604
+ return;
2605
+ }
2606
+ const timer = setTimeout(() => child.kill(), LIMIT_MS);
2607
+ const feed = (d) => {
2608
+ const s = d.toString();
2609
+ stdout += s;
2610
+ if (opts?.onLine) {
2611
+ for (const line of s.split(/\r?\n/)) if (line.trim()) opts.onLine(line);
2612
+ }
2613
+ };
2614
+ child.stdout?.on("data", feed);
2615
+ child.stderr?.on("data", feed);
2616
+ child.on("error", (e) => {
2617
+ clearTimeout(timer);
2618
+ resolve10({ code: 127, stdout: stdout + e.message });
2619
+ });
2620
+ child.on("close", (code) => {
2621
+ clearTimeout(timer);
2622
+ resolve10({ code: code ?? 1, stdout });
2623
+ });
2624
+ });
2625
+
2499
2626
  // ../server/src/approvals.ts
2500
2627
  import { randomUUID as randomUUID2 } from "node:crypto";
2501
2628
 
@@ -2927,7 +3054,7 @@ import { spawn } from "node:child_process";
2927
3054
  import { randomUUID as randomUUID4 } from "node:crypto";
2928
3055
  import { chmodSync as chmodSync2, mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
2929
3056
  import { tmpdir } from "node:os";
2930
- import { join as join6 } from "node:path";
3057
+ import { join as join7 } from "node:path";
2931
3058
  var MAX_CODE_CHARS = 5e4;
2932
3059
  var MAX_OUTPUT_CHARS = 2e4;
2933
3060
  var MAX_EXTRA_FILES = 10;
@@ -2987,15 +3114,15 @@ var CodeBox = class {
2987
3114
  if (extra.reduce((sum, [, body]) => sum + body.length, 0) > MAX_EXTRA_FILE_CHARS) return { ok: false, error: "The sandbox files are too large." };
2988
3115
  const setup = SETUP[input.language];
2989
3116
  const timeoutMs = input.timeoutSec && input.timeoutSec > 0 ? Math.min(input.timeoutSec * 1e3, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
2990
- const dir = mkdtempSync2(join6(tmpdir(), "friend-code-"));
3117
+ const dir = mkdtempSync2(join7(tmpdir(), "friend-code-"));
2991
3118
  try {
2992
- const file = join6(dir, setup.file);
3119
+ const file = join7(dir, setup.file);
2993
3120
  writeFileSync3(file, input.code);
2994
3121
  chmodSync2(dir, 493);
2995
3122
  chmodSync2(file, 420);
2996
3123
  for (const [name, body] of extra) {
2997
- writeFileSync3(join6(dir, name), body);
2998
- chmodSync2(join6(dir, name), 420);
3124
+ writeFileSync3(join7(dir, name), body);
3125
+ chmodSync2(join7(dir, name), 420);
2999
3126
  }
3000
3127
  const args = [
3001
3128
  "run",
@@ -3130,12 +3257,12 @@ var Memory = class {
3130
3257
  // ../server/src/app.ts
3131
3258
  import { dirname as dirname3, resolve as resolve2 } from "node:path";
3132
3259
  import { fileURLToPath } from "node:url";
3133
- import { join as join10 } from "node:path";
3260
+ import { join as join12 } from "node:path";
3134
3261
 
3135
3262
  // ../server/src/config.ts
3136
- import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
3137
- import { homedir } from "node:os";
3138
- import { join as join7 } from "node:path";
3263
+ import { chmodSync as chmodSync3, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
3264
+ import { homedir as homedir2 } from "node:os";
3265
+ import { join as join8 } from "node:path";
3139
3266
  var DEFAULT_CLAUDE_CODE = { model: "sonnet", command: "claude", fallback: "ollama" };
3140
3267
  function sanitizeClaudeCode(raw) {
3141
3268
  const r = raw && typeof raw === "object" ? raw : {};
@@ -3208,12 +3335,13 @@ var DEFAULT_DESTRUCTIVE_PATTERNS = [
3208
3335
  String.raw`\b(iex|invoke-expression)\b`
3209
3336
  ];
3210
3337
  function defaultDataDir() {
3211
- return process.env.FRIEND_HOME ?? join7(homedir(), ".friend");
3338
+ return process.env.FRIEND_HOME ?? join8(homedir2(), ".friend");
3212
3339
  }
3213
3340
  var defaults = (dataDir) => ({
3214
3341
  bind: "127.0.0.1",
3215
3342
  port: 8787,
3216
3343
  publicUrl: null,
3344
+ https: false,
3217
3345
  trustProxy: false,
3218
3346
  allowedOrigins: [],
3219
3347
  allowedHosts: [],
@@ -3230,7 +3358,7 @@ var defaults = (dataDir) => ({
3230
3358
  dataDir
3231
3359
  });
3232
3360
  function configPath(dataDir) {
3233
- return join7(dataDir, "config.json");
3361
+ return join8(dataDir, "config.json");
3234
3362
  }
3235
3363
  function saveConfig(cfg) {
3236
3364
  const { dataDir, ...rest } = cfg;
@@ -3245,7 +3373,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3245
3373
  mkdirSync4(dataDir, { recursive: true });
3246
3374
  const file = configPath(dataDir);
3247
3375
  const base = defaults(dataDir);
3248
- if (!existsSync4(file)) {
3376
+ if (!existsSync5(file)) {
3249
3377
  const { dataDir: _d, ...rest } = base;
3250
3378
  saveConfig({ ...rest, dataDir });
3251
3379
  return base;
@@ -3255,6 +3383,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3255
3383
  return {
3256
3384
  ...base,
3257
3385
  ...stored,
3386
+ https: stored.https === true,
3258
3387
  destructivePatterns,
3259
3388
  ollama: { ...DEFAULT_OLLAMA, ...stored.ollama },
3260
3389
  claudeCode: sanitizeClaudeCode(stored.claudeCode),
@@ -3268,7 +3397,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3268
3397
  // ../server/src/db.ts
3269
3398
  import Database from "better-sqlite3";
3270
3399
  import { mkdirSync as mkdirSync5 } from "node:fs";
3271
- import { join as join8 } from "node:path";
3400
+ import { join as join9 } from "node:path";
3272
3401
 
3273
3402
  // ../server/src/migrations.ts
3274
3403
  var MIGRATIONS = [
@@ -3385,7 +3514,7 @@ create table plugins (
3385
3514
  // ../server/src/db.ts
3386
3515
  function openDb(dataDir) {
3387
3516
  mkdirSync5(dataDir, { recursive: true });
3388
- const db = new Database(join8(dataDir, "friend.db"));
3517
+ const db = new Database(join9(dataDir, "friend.db"));
3389
3518
  db.pragma("journal_mode = WAL");
3390
3519
  db.pragma("foreign_keys = ON");
3391
3520
  db.exec("create table if not exists schema_version (id integer primary key, applied_at integer not null)");
@@ -3483,16 +3612,16 @@ var DeviceHub = class {
3483
3612
  import fastifyCookie from "@fastify/cookie";
3484
3613
  import fastifyStatic from "@fastify/static";
3485
3614
  import Fastify from "fastify";
3486
- import { existsSync as existsSync5 } from "node:fs";
3615
+ import { existsSync as existsSync6 } from "node:fs";
3487
3616
  import { isIP } from "node:net";
3488
3617
  import { WebSocket, WebSocketServer } from "ws";
3489
3618
  import { z as z10 } from "zod";
3490
3619
 
3491
3620
  // ../server/src/http-error.ts
3492
3621
  var HttpError = class extends Error {
3493
- constructor(status, message) {
3622
+ constructor(status2, message) {
3494
3623
  super(message);
3495
- this.status = status;
3624
+ this.status = status2;
3496
3625
  }
3497
3626
  status;
3498
3627
  };
@@ -3558,7 +3687,7 @@ function createGateway(deps) {
3558
3687
  forceCloseConnections: true
3559
3688
  // shutdown must not wait for idle keep-alive connections
3560
3689
  };
3561
- const app = Fastify(options);
3690
+ const app = Fastify(deps.tls ? { ...options, https: { key: deps.tls.key, cert: deps.tls.cert } } : options);
3562
3691
  app.register(fastifyCookie);
3563
3692
  const hostOf = (header) => {
3564
3693
  const h = (header ?? "").trim().toLowerCase();
@@ -3768,6 +3897,24 @@ function createGateway(deps) {
3768
3897
  return connectorError(reply, e, "add");
3769
3898
  }
3770
3899
  });
3900
+ app.get("/api/connectors/quick/:preset", { onRequest: connectorGuard }, async (req, reply) => {
3901
+ try {
3902
+ return deps.connectors.quickCheck(req.params.preset);
3903
+ } catch (e) {
3904
+ return connectorError(reply, e, "quick-check");
3905
+ }
3906
+ });
3907
+ for (const action of ["install", "connect"]) {
3908
+ app.post(`/api/connectors/quick/:preset/${action}`, { onRequest: connectorGuard }, async (req, reply) => {
3909
+ const body = z10.object({ pin: z10.string().min(1).max(32) }).safeParse(req.body);
3910
+ if (!body.success) return reply.code(400).send({ error: "Enter your PIN to continue" });
3911
+ try {
3912
+ return await (action === "install" ? deps.connectors.quickInstall : deps.connectors.quickConnect)(req.params.preset, body.data.pin, req.ip);
3913
+ } catch (e) {
3914
+ return connectorError(reply, e, `quick-${action}`);
3915
+ }
3916
+ });
3917
+ }
3771
3918
  app.post("/api/connectors/:id/enabled", { onRequest: connectorGuard }, async (req, reply) => {
3772
3919
  const body = z10.object({ enabled: z10.boolean(), pin: z10.string().min(1).max(32).optional() }).safeParse(req.body);
3773
3920
  if (!body.success) return reply.code(400).send({ error: "Invalid input" });
@@ -3903,7 +4050,7 @@ function createGateway(deps) {
3903
4050
  deps.onLock?.();
3904
4051
  return { ok: true };
3905
4052
  });
3906
- if (deps.webDir && existsSync5(deps.webDir)) {
4053
+ if (deps.webDir && existsSync6(deps.webDir)) {
3907
4054
  app.register(fastifyStatic, { root: deps.webDir });
3908
4055
  app.setNotFoundHandler((req, reply) => {
3909
4056
  if (req.method === "GET" && !req.url.startsWith("/api")) return reply.sendFile("index.html");
@@ -3928,6 +4075,7 @@ function createGateway(deps) {
3928
4075
  const fwd = config.trustProxy ? String(req.headers["x-forwarded-for"] ?? "").split(",").at(-1)?.trim() : "";
3929
4076
  return fwd || req.socket.remoteAddress || "unknown";
3930
4077
  };
4078
+ let activeOwner = null;
3931
4079
  function handleClient(ws, req) {
3932
4080
  if (!originAllowed(req)) {
3933
4081
  audit.log("anonymous", "ws.origin_refused", { origin: req.headers.origin });
@@ -3936,6 +4084,19 @@ function createGateway(deps) {
3936
4084
  const token = parseCookies(req.headers.cookie)[COOKIE_NAME];
3937
4085
  const session = token ? auth.resolveSession(token) : null;
3938
4086
  if (!session) return ws.close(4401, "unauthorized");
4087
+ const params = new URL(req.url ?? "/", "http://localhost").searchParams;
4088
+ const tab = (params.get("tab") ?? "").slice(0, 64);
4089
+ if (tab) {
4090
+ if (activeOwner && activeOwner.ws.readyState === WebSocket.OPEN) {
4091
+ if (params.get("takeover") !== "1" && activeOwner.tab !== tab) return ws.close(4409, "in use elsewhere");
4092
+ activeOwner.ws.close(4410, "replaced");
4093
+ }
4094
+ const mine = { ws, tab };
4095
+ activeOwner = mine;
4096
+ ws.on("close", () => {
4097
+ if (activeOwner === mine) activeOwner = null;
4098
+ });
4099
+ }
3939
4100
  const conn = {
3940
4101
  ip: clientIp(req),
3941
4102
  send: (m) => {
@@ -3946,6 +4107,7 @@ function createGateway(deps) {
3946
4107
  const dispose = deps.onClientConnect?.(session, conn);
3947
4108
  ws.on("close", () => dispose?.());
3948
4109
  ws.on("message", (data) => {
4110
+ if (tab && activeOwner?.ws !== ws) return;
3949
4111
  let json;
3950
4112
  try {
3951
4113
  json = JSON.parse(String(data));
@@ -4125,8 +4287,8 @@ var ClaudeProvider = class {
4125
4287
 
4126
4288
  // ../server/src/provider/claude-code/provider.ts
4127
4289
  import { createHash as createHash3 } from "node:crypto";
4128
- import { chmodSync as chmodSync4, existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
4129
- import { join as join9 } from "node:path";
4290
+ import { chmodSync as chmodSync4, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
4291
+ import { join as join10 } from "node:path";
4130
4292
 
4131
4293
  // ../server/src/provider/claude-code/bridge.ts
4132
4294
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -4411,7 +4573,7 @@ var NOT_SIGNED_IN = /not logged in|please run \/login|invalid api key|authentica
4411
4573
  var ClaudeCodeProvider = class {
4412
4574
  constructor(opts) {
4413
4575
  this.opts = opts;
4414
- this.home = join9(opts.dataDir, "claude");
4576
+ this.home = join10(opts.dataDir, "claude");
4415
4577
  this.now = opts.now ?? Date.now;
4416
4578
  this.bridge = new ToolBridge((call) => this.owner.inbox.push({ kind: "call", call }));
4417
4579
  }
@@ -4641,7 +4803,7 @@ ${item.stderr}`;
4641
4803
  "local",
4642
4804
  "--system-prompt",
4643
4805
  req.system,
4644
- ...conv.guest ? ["--no-session-persistence"] : ["--mcp-config", join9(this.home, "mcp.json"), "--allowedTools", req.tools.map((t) => `mcp__friend__${encodeToolName(t.name)}`).join(","), ...resume ? ["--resume", resume] : []]
4806
+ ...conv.guest ? ["--no-session-persistence"] : ["--mcp-config", join10(this.home, "mcp.json"), "--allowedTools", req.tools.map((t) => `mcp__friend__${encodeToolName(t.name)}`).join(","), ...resume ? ["--resume", resume] : []]
4645
4807
  ];
4646
4808
  const proc = new ClaudeProc(
4647
4809
  {
@@ -4672,7 +4834,7 @@ ${item.stderr}`;
4672
4834
  startBridge() {
4673
4835
  this.bridgeReady ??= (async () => {
4674
4836
  await this.bridge.start();
4675
- const file = join9(this.home, "mcp.json");
4837
+ const file = join10(this.home, "mcp.json");
4676
4838
  writeFileSync5(file, JSON.stringify({ mcpServers: { friend: { type: "http", url: this.bridge.url, headers: { Authorization: `Bearer ${this.bridge.token}` } } } }));
4677
4839
  try {
4678
4840
  chmodSync4(file, 384);
@@ -4682,10 +4844,10 @@ ${item.stderr}`;
4682
4844
  return this.bridgeReady;
4683
4845
  }
4684
4846
  // ---- the session id, kept so a restart of Friend can pick the conversation up again ----
4685
- sessionFile = () => join9(this.home, "session-id");
4847
+ sessionFile = () => join10(this.home, "session-id");
4686
4848
  readSession() {
4687
4849
  try {
4688
- return existsSync6(this.sessionFile()) ? readFileSync4(this.sessionFile(), "utf8").trim() || null : null;
4850
+ return existsSync7(this.sessionFile()) ? readFileSync4(this.sessionFile(), "utf8").trim() || null : null;
4689
4851
  } catch {
4690
4852
  return null;
4691
4853
  }
@@ -5120,6 +5282,63 @@ var OllamaProvider = class {
5120
5282
  }
5121
5283
  };
5122
5284
 
5285
+ // ../server/src/tls.ts
5286
+ import { chmodSync as chmodSync5, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
5287
+ import { networkInterfaces } from "node:os";
5288
+ import { isIP as isIP2 } from "node:net";
5289
+ import { join as join11 } from "node:path";
5290
+ import { generate } from "selfsigned";
5291
+ var DAYS = 825;
5292
+ var RENEW_BEFORE_MS = 30 * 24 * 36e5;
5293
+ function lanAddresses(ifaces = networkInterfaces) {
5294
+ const out = [];
5295
+ for (const list2 of Object.values(ifaces())) {
5296
+ for (const i of list2 ?? []) if (i.family === "IPv4" && !i.internal && !out.includes(i.address)) out.push(i.address);
5297
+ }
5298
+ return out;
5299
+ }
5300
+ function tlsHosts(extra = [], ifaces) {
5301
+ return [.../* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", ...lanAddresses(ifaces), ...extra.map((h) => h.toLowerCase()).filter(Boolean)])];
5302
+ }
5303
+ async function ensureTlsCert(dataDir, extraHosts = [], ifaces, now = Date.now) {
5304
+ const dir = join11(dataDir, "tls");
5305
+ const keyFile = join11(dir, "key.pem");
5306
+ const certFile = join11(dir, "cert.pem");
5307
+ const metaFile = join11(dir, "meta.json");
5308
+ const hosts = tlsHosts(extraHosts, ifaces);
5309
+ if (existsSync8(keyFile) && existsSync8(certFile) && existsSync8(metaFile)) {
5310
+ try {
5311
+ const meta = JSON.parse(readFileSync5(metaFile, "utf8"));
5312
+ const covered = hosts.every((h) => meta.hosts.includes(h));
5313
+ if (covered && meta.expires - now() > RENEW_BEFORE_MS) return { key: readFileSync5(keyFile, "utf8"), cert: readFileSync5(certFile, "utf8") };
5314
+ } catch {
5315
+ }
5316
+ }
5317
+ const altNames = hosts.map((h) => isIP2(h) ? { type: 7, ip: h } : { type: 2, value: h });
5318
+ const pems = await generate([{ name: "commonName", value: "Friend" }], {
5319
+ keySize: 2048,
5320
+ notBeforeDate: new Date(now() - 24 * 36e5),
5321
+ // a day back, so a phone whose clock is a little slow still accepts it
5322
+ notAfterDate: new Date(now() + DAYS * 24 * 36e5),
5323
+ algorithm: "sha256",
5324
+ extensions: [
5325
+ { name: "basicConstraints", cA: false },
5326
+ { name: "keyUsage", digitalSignature: true, keyEncipherment: true },
5327
+ { name: "extKeyUsage", serverAuth: true },
5328
+ { name: "subjectAltName", altNames }
5329
+ ]
5330
+ });
5331
+ mkdirSync7(dir, { recursive: true });
5332
+ writeFileSync6(keyFile, pems.private);
5333
+ writeFileSync6(certFile, pems.cert);
5334
+ writeFileSync6(metaFile, JSON.stringify({ hosts, expires: now() + DAYS * 24 * 36e5 }));
5335
+ try {
5336
+ chmodSync5(keyFile, 384);
5337
+ } catch {
5338
+ }
5339
+ return { key: pems.private, cert: pems.cert };
5340
+ }
5341
+
5123
5342
  // ../server/src/voice/challenge.ts
5124
5343
  import { randomUUID as randomUUID6 } from "node:crypto";
5125
5344
  var Challenges = class {
@@ -5181,22 +5400,22 @@ var Challenges = class {
5181
5400
 
5182
5401
  // ../server/src/voice/crypto.ts
5183
5402
  import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
5184
- import { chmodSync as chmodSync5, existsSync as existsSync7, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
5403
+ import { chmodSync as chmodSync6, existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "node:fs";
5185
5404
  import { dirname as dirname2 } from "node:path";
5186
5405
  var KEY_BYTES = 32;
5187
5406
  var IV_BYTES = 12;
5188
5407
  var TAG_BYTES = 16;
5189
5408
  function loadOrCreateKey(path) {
5190
- if (existsSync7(path)) {
5191
- const key2 = readFileSync5(path);
5409
+ if (existsSync9(path)) {
5410
+ const key2 = readFileSync6(path);
5192
5411
  if (key2.length !== KEY_BYTES) throw new Error(`invalid voice key file (expected ${KEY_BYTES} bytes): ${path}`);
5193
5412
  return key2;
5194
5413
  }
5195
- mkdirSync7(dirname2(path), { recursive: true });
5414
+ mkdirSync8(dirname2(path), { recursive: true });
5196
5415
  const key = randomBytes3(KEY_BYTES);
5197
- writeFileSync6(path, key, { mode: 384 });
5416
+ writeFileSync7(path, key, { mode: 384 });
5198
5417
  try {
5199
- chmodSync5(path, 384);
5418
+ chmodSync6(path, 384);
5200
5419
  } catch {
5201
5420
  }
5202
5421
  return key;
@@ -5614,7 +5833,7 @@ async function createApp(opts) {
5614
5833
  const camera = new CameraBroker(opts.cameraTimeoutMs);
5615
5834
  const memory = new Memory(db);
5616
5835
  const tasks = new Tasks(db);
5617
- const plugins = new Plugins(db, join10(opts.dataDir, "plugins"));
5836
+ const plugins = new Plugins(db, join12(opts.dataDir, "plugins"));
5618
5837
  const hub = new McpHub(new ConnectorStore(opts.dataDir));
5619
5838
  const admin = makeAdmin({ hub, plugins, dataDir: opts.dataDir, audit: (what, detail) => audit.log("agent", what, detail) });
5620
5839
  function commitSettings(patch, how) {
@@ -5720,7 +5939,7 @@ async function createApp(opts) {
5720
5939
  const voice2 = opts.voice;
5721
5940
  const speaker = voice2 ? new Speaker(voice2, audit, () => config.voice.ttsVoice) : null;
5722
5941
  const voiceAvailable = () => !!voice2 && voice2.available();
5723
- const voiceprint = voice2 ? new Voiceprint(db, loadOrCreateKey(join10(opts.dataDir, "voice.key"))) : null;
5942
+ const voiceprint = voice2 ? new Voiceprint(db, loadOrCreateKey(join12(opts.dataDir, "voice.key"))) : null;
5724
5943
  const enrollment = voiceprint ? new Enrollment(voiceprint, SPEAKER_MODEL) : null;
5725
5944
  const challenges = new Challenges((id2) => {
5726
5945
  broadcast({ type: "voice.challenge.end", id: id2 });
@@ -5837,7 +6056,7 @@ async function createApp(opts) {
5837
6056
  if (!isPluginId(id2)) throw new HttpError(400, "That is not a plugin name.");
5838
6057
  if (!listCatalog(catalogDir).some((c) => c.id === id2)) throw new HttpError(404, "That plugin is not in the catalog.");
5839
6058
  await requirePin(pin, ip);
5840
- const r = plugins.install(join10(catalogDir, id2));
6059
+ const r = plugins.install(join12(catalogDir, id2));
5841
6060
  if (!r.ok) throw new HttpError(400, `That plugin cannot be installed: ${r.error}`);
5842
6061
  audit.log("owner", "plugin.installed", { id: r.id, upgraded: r.upgraded, ip });
5843
6062
  return { id: r.id, upgraded: r.upgraded };
@@ -5892,6 +6111,24 @@ async function createApp(opts) {
5892
6111
  throw new HttpError(404, e.message);
5893
6112
  }
5894
6113
  },
6114
+ quickCheck: (preset) => {
6115
+ const c = quickCheck(preset, { home: opts.quick?.home });
6116
+ if (!c) throw new HttpError(404, "That is not a one-click connector.");
6117
+ return c;
6118
+ },
6119
+ quickInstall: async (preset, pin, ip) => {
6120
+ if (!isQuickPreset(preset)) throw new HttpError(404, "That is not a one-click connector.");
6121
+ await requirePin(pin, ip);
6122
+ audit.log("owner", "connector.quick_install", { preset, ip });
6123
+ return quickInstall(preset, { hub, run: opts.quick?.run ?? runCommand, home: opts.quick?.home });
6124
+ },
6125
+ quickConnect: async (preset, pin, ip) => {
6126
+ if (!isQuickPreset(preset)) throw new HttpError(404, "That is not a one-click connector.");
6127
+ await requirePin(pin, ip);
6128
+ const result = await quickConnect(preset, { hub, run: opts.quick?.run ?? runCommand, home: opts.quick?.home });
6129
+ audit.log("owner", "connector.quick_connect", { preset, ok: result.ok, extension: result.extension, ip });
6130
+ return result;
6131
+ },
5895
6132
  remove: async (id2) => {
5896
6133
  try {
5897
6134
  await hub.remove(id2);
@@ -5901,7 +6138,9 @@ async function createApp(opts) {
5901
6138
  }
5902
6139
  }
5903
6140
  };
6141
+ const tls = config.https ? await ensureTlsCert(opts.dataDir, [...config.allowedHosts, ...config.publicUrl ? [new URL(config.publicUrl).hostname] : []]) : void 0;
5904
6142
  const gateway = createGateway({
6143
+ ...tls ? { tls } : {},
5905
6144
  connectors: connectorsApi,
5906
6145
  voicePreview: async (id2) => {
5907
6146
  if (!voice2 || !voiceAvailable()) throw new HttpError(503, "Voice is not available. Run: friend voice setup");
@@ -6118,11 +6357,10 @@ async function createApp(opts) {
6118
6357
  camera.cancelAll(broadcast);
6119
6358
  agent.cancel();
6120
6359
  approvals.denyAll("Shutting down");
6121
- await gateway.close();
6122
- await provider.close?.().catch(() => {
6123
- });
6124
- await hub.close().catch(() => {
6125
- });
6360
+ await Promise.all([gateway.close().catch(() => {
6361
+ }), provider.close?.().catch(() => {
6362
+ }), hub.close().catch(() => {
6363
+ })]);
6126
6364
  db.close();
6127
6365
  }
6128
6366
  };
@@ -6250,7 +6488,7 @@ var SidecarProcess = class {
6250
6488
  import { createInterface as createInterface2 } from "node:readline";
6251
6489
 
6252
6490
  // ../agent-cli/src/commands/config.ts
6253
- import { isIP as isIP2 } from "node:net";
6491
+ import { isIP as isIP3 } from "node:net";
6254
6492
  var list = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
6255
6493
  var bool = (v) => {
6256
6494
  if (v !== "true" && v !== "false") throw new Error("Expected true or false");
@@ -6280,10 +6518,11 @@ var PARSERS = {
6280
6518
  model: (v) => v,
6281
6519
  bind: (v) => {
6282
6520
  const t = v.trim();
6283
- if (t !== "localhost" && isIP2(t) === 0) throw new Error("bind must be an IP address such as 127.0.0.1 (this PC only) or 0.0.0.0 (also your network), or localhost. Easier: friend lan on");
6521
+ if (t !== "localhost" && isIP3(t) === 0) throw new Error("bind must be an IP address such as 127.0.0.1 (this PC only) or 0.0.0.0 (also your network), or localhost. Easier: friend lan on");
6284
6522
  return t;
6285
6523
  },
6286
6524
  publicUrl: (v) => v === "" ? null : v,
6525
+ https: bool,
6287
6526
  port: (v) => {
6288
6527
  const n = Number(v);
6289
6528
  if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error("Port must be a number between 0 and 65535");
@@ -6336,6 +6575,7 @@ var KEY_HELP = {
6336
6575
  provider: "which model answers: claude (API key), claude-code (your Claude subscription) or ollama (on this PC)",
6337
6576
  bind: "address Friend listens on: 127.0.0.1 is this PC only, 0.0.0.0 is your whole network (easier: friend lan on)",
6338
6577
  port: "the web port (easier: friend lan port N)",
6578
+ https: "true serves a secure https:// address (Friend makes its own certificate), so a phone may use the microphone (easier: friend lan https on)",
6339
6579
  publicUrl: "the address people use when Friend is behind your own domain, or empty",
6340
6580
  roots: "folders Friend may read and write freely, separated by commas",
6341
6581
  allowedOrigins: "extra web addresses allowed to connect, separated by commas",
@@ -6400,8 +6640,8 @@ import { resolve as resolve6 } from "node:path";
6400
6640
  import { WebSocket as WebSocket2 } from "ws";
6401
6641
 
6402
6642
  // ../agent-cli/src/tools/fs.ts
6403
- import { closeSync, mkdirSync as mkdirSync8, openSync, readSync, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync7 } from "node:fs";
6404
- import { dirname as dirname5, join as join11 } from "node:path";
6643
+ import { closeSync, mkdirSync as mkdirSync9, openSync, readSync, readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync8 } from "node:fs";
6644
+ import { dirname as dirname5, join as join13 } from "node:path";
6405
6645
 
6406
6646
  // ../agent-cli/src/tools/roots.ts
6407
6647
  import { lstatSync as lstatSync2, realpathSync } from "node:fs";
@@ -6463,7 +6703,7 @@ function fsList(args, ctx) {
6463
6703
  if (e.isDirectory()) return `[dir] ${e.name}`;
6464
6704
  let size = 0;
6465
6705
  try {
6466
- size = statSync(join11(g.abs, e.name)).size;
6706
+ size = statSync(join13(g.abs, e.name)).size;
6467
6707
  } catch {
6468
6708
  }
6469
6709
  return `[file] ${e.name} (${size} bytes)`;
@@ -6504,8 +6744,8 @@ function fsWrite(args, ctx) {
6504
6744
  if ("error" in g) return { ok: false, error: g.error };
6505
6745
  if (Buffer.byteLength(args.content) > MAX_WRITE) return { ok: false, error: "Content too large (5 MB limit)" };
6506
6746
  try {
6507
- mkdirSync8(dirname5(g.abs), { recursive: true });
6508
- writeFileSync7(g.abs, args.content);
6747
+ mkdirSync9(dirname5(g.abs), { recursive: true });
6748
+ writeFileSync8(g.abs, args.content);
6509
6749
  return { ok: true, output: `Wrote ${Buffer.byteLength(args.content)} bytes to ${g.abs}`, truncated: false };
6510
6750
  } catch (e) {
6511
6751
  return { ok: false, error: e.message };
@@ -6514,7 +6754,7 @@ function fsWrite(args, ctx) {
6514
6754
 
6515
6755
  // ../agent-cli/src/tools/shell.ts
6516
6756
  import { spawn as spawn4, spawnSync } from "node:child_process";
6517
- function killTree(child) {
6757
+ function killTree2(child) {
6518
6758
  if (!child.pid) return;
6519
6759
  if (process.platform === "win32") {
6520
6760
  spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
@@ -6526,7 +6766,7 @@ function killTree(child) {
6526
6766
  }
6527
6767
  }
6528
6768
  }
6529
- function runCommand(command, cwdArg, ctx, argv) {
6769
+ function runCommand2(command, cwdArg, ctx, argv) {
6530
6770
  let cwd = ctx.roots[0] ?? process.cwd();
6531
6771
  if (cwdArg) {
6532
6772
  const r = resolveInRoots(cwdArg, ctx.roots);
@@ -6559,7 +6799,7 @@ function runCommand(command, cwdArg, ctx, argv) {
6559
6799
  child.stderr.on("data", take);
6560
6800
  const timer = setTimeout(() => {
6561
6801
  timedOut = true;
6562
- killTree(child);
6802
+ killTree2(child);
6563
6803
  }, ctx.timeoutMs);
6564
6804
  child.on("error", (e) => {
6565
6805
  clearTimeout(timer);
@@ -6617,7 +6857,7 @@ async function systemInfo(sampleMs = 250) {
6617
6857
  return { ok: true, output: lines.join("\n"), truncated: false };
6618
6858
  }
6619
6859
  function processList(ctx) {
6620
- return process.platform === "win32" ? runCommand("", void 0, ctx, { file: "tasklist.exe", args: ["/fo", "csv", "/nh"] }) : runCommand("", void 0, ctx, { file: "ps", args: ["-eo", "pid,comm"] });
6860
+ return process.platform === "win32" ? runCommand2("", void 0, ctx, { file: "tasklist.exe", args: ["/fo", "csv", "/nh"] }) : runCommand2("", void 0, ctx, { file: "ps", args: ["-eo", "pid,comm"] });
6621
6861
  }
6622
6862
 
6623
6863
  // ../agent-cli/src/tools/index.ts
@@ -6641,7 +6881,7 @@ async function runTool(name, args, ctx) {
6641
6881
  case "process.list":
6642
6882
  return await processList(ctx);
6643
6883
  case "shell.run":
6644
- return await runCommand(a.command, a.cwd, ctx);
6884
+ return await runCommand2(a.command, a.cwd, ctx);
6645
6885
  default:
6646
6886
  return { ok: false, error: `Unknown tool: ${name}` };
6647
6887
  }
@@ -6671,10 +6911,10 @@ function startDeviceNode(opts) {
6671
6911
  state = s;
6672
6912
  opts.onState?.(s);
6673
6913
  };
6674
- function connect() {
6914
+ function connect2() {
6675
6915
  if (stopped) return;
6676
6916
  setState("connecting");
6677
- const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` } });
6917
+ const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` }, ...opts.ca ? { ca: opts.ca } : {} });
6678
6918
  ws = sock;
6679
6919
  sock.on("error", () => {
6680
6920
  });
@@ -6719,10 +6959,10 @@ function startDeviceNode(opts) {
6719
6959
  if (opts.reconnect === false) return;
6720
6960
  const delay = Math.min(base * 2 ** attempt, 1e4);
6721
6961
  attempt += 1;
6722
- timer = setTimeout(connect, delay);
6962
+ timer = setTimeout(connect2, delay);
6723
6963
  });
6724
6964
  }
6725
- connect();
6965
+ connect2();
6726
6966
  return {
6727
6967
  connected,
6728
6968
  state: () => state,
@@ -6741,15 +6981,15 @@ function startDeviceNode(opts) {
6741
6981
 
6742
6982
  // ../agent-cli/src/commands/start.ts
6743
6983
  import { randomBytes as randomBytes4 } from "node:crypto";
6744
- import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "node:fs";
6745
- import { dirname as dirname7, join as join13, resolve as resolve5 } from "node:path";
6984
+ import { existsSync as existsSync11, readFileSync as readFileSync8, writeFileSync as writeFileSync10 } from "node:fs";
6985
+ import { dirname as dirname7, join as join15, resolve as resolve5 } from "node:path";
6746
6986
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6747
6987
 
6748
6988
  // ../agent-cli/src/commands/lan.ts
6749
- import { networkInterfaces } from "node:os";
6989
+ import { networkInterfaces as networkInterfaces2 } from "node:os";
6750
6990
  var LOOPBACK3 = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
6751
6991
  var isLoopbackBind = (bind) => LOOPBACK3.has(bind);
6752
- function lanAddresses(ifaces = networkInterfaces) {
6992
+ function lanAddresses2(ifaces = networkInterfaces2) {
6753
6993
  const out = [];
6754
6994
  for (const list2 of Object.values(ifaces())) {
6755
6995
  for (const i of list2 ?? []) {
@@ -6758,11 +6998,12 @@ function lanAddresses(ifaces = networkInterfaces) {
6758
6998
  }
6759
6999
  return out;
6760
7000
  }
6761
- function urlsFor(bind, port, ifaces = networkInterfaces) {
6762
- const local = `http://127.0.0.1:${port}`;
7001
+ function urlsFor(bind, port, ifaces = networkInterfaces2, https = false) {
7002
+ const scheme = https ? "https" : "http";
7003
+ const local = `${scheme}://127.0.0.1:${port}`;
6763
7004
  if (isLoopbackBind(bind)) return { local, network: [] };
6764
- if (bind === "0.0.0.0" || bind === "::") return { local, network: lanAddresses(ifaces).map((a) => `http://${a}:${port}`) };
6765
- return { local, network: [`http://${bind}:${port}`] };
7005
+ if (bind === "0.0.0.0" || bind === "::") return { local, network: lanAddresses2(ifaces).map((a) => `${scheme}://${a}:${port}`) };
7006
+ return { local, network: [`${scheme}://${bind}:${port}`] };
6766
7007
  }
6767
7008
  var PORT_HELP = "The port must be a whole number from 1024 to 65535.";
6768
7009
  function parsePort(v) {
@@ -6770,34 +7011,58 @@ function parsePort(v) {
6770
7011
  if (v === void 0 || v.trim() === "" || !Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(PORT_HELP);
6771
7012
  return n;
6772
7013
  }
6773
- function show(io2, bind, port, ifaces) {
6774
- const u = urlsFor(bind, port, ifaces);
7014
+ function show(io2, bind, port, ifaces, https = false) {
7015
+ const u = urlsFor(bind, port, ifaces, https);
6775
7016
  io2.out(`Listens on: ${bind}:${port}`);
6776
7017
  io2.out(`On this PC: ${u.local}`);
6777
7018
  if (u.network.length) for (const n of u.network) io2.out(`On your network: ${n}`);
6778
7019
  else if (isLoopbackBind(bind)) io2.out("On your network: not reachable (only this PC). Turn it on with: friend lan on");
6779
7020
  else io2.out("On your network: no network address found. Check that this PC is connected.");
6780
7021
  }
6781
- async function lanCommand(args, io2, ifaces = networkInterfaces) {
7022
+ async function lanCommand(args, io2, ifaces = networkInterfaces2) {
6782
7023
  const [sub, ...rest] = args;
6783
7024
  const cfg = loadConfig(io2.dataDir);
6784
7025
  switch (sub) {
6785
7026
  case void 0:
6786
7027
  case "status":
6787
- show(io2, cfg.bind, cfg.port, ifaces);
7028
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
7029
+ io2.out(cfg.https ? "Secure (https): on. A phone can use the microphone and camera." : "Secure (https): off. A phone cannot use the microphone or camera. Turn it on with: friend lan https on");
7030
+ return 0;
7031
+ case "https": {
7032
+ if (rest[0] !== "on" && rest[0] !== "off") {
7033
+ io2.err("Usage: friend lan https on | off");
7034
+ return 1;
7035
+ }
7036
+ cfg.https = rest[0] === "on";
7037
+ saveConfig(cfg);
7038
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
7039
+ io2.out("");
7040
+ io2.out("Restart Friend to apply (stop it, then: friend start).");
7041
+ if (cfg.https) {
7042
+ io2.out("Friend makes its own security certificate, so the first time a phone opens the https:// address it shows a warning:");
7043
+ io2.out(" Android Chrome: Advanced, then Proceed. iPhone Safari: Show Details, then visit this website.");
7044
+ io2.out("After that, the microphone and camera work from the phone. Only that address is affected; nothing is installed on the phone.");
7045
+ if (isLoopbackBind(cfg.bind)) io2.out("Friend is still only reachable from this PC. To open it from a phone too: friend lan on");
7046
+ }
6788
7047
  return 0;
7048
+ }
6789
7049
  case "on": {
6790
7050
  const portFlag = rest.indexOf("--port");
6791
7051
  if (portFlag >= 0) cfg.port = parsePort(rest[portFlag + 1]);
6792
7052
  cfg.bind = "0.0.0.0";
7053
+ if (rest.includes("--https")) cfg.https = true;
6793
7054
  saveConfig(cfg);
6794
- show(io2, cfg.bind, cfg.port, ifaces);
7055
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
6795
7056
  io2.out("");
6796
7057
  io2.out("Restart Friend to apply (stop it, then: friend start).");
6797
7058
  io2.out("Things to know:");
6798
7059
  io2.out(" - Anyone on your network can open the sign-in page. Your password and PIN still guard everything; use a strong password.");
6799
- io2.out(" - The connection is plain http: other people on the network could read it. Use it only on a network you trust, or put Friend behind HTTPS (see publicUrl in the README).");
6800
- io2.out(" - Browsers allow the microphone and camera only on localhost or HTTPS, so voice and camera will not work from another device over plain http.");
7060
+ if (cfg.https) {
7061
+ io2.out(" - Secure (https) is on. The first time a phone opens the address it shows a certificate warning (Advanced, then Proceed); after that the microphone works.");
7062
+ } else {
7063
+ io2.out(" - The connection is plain http: other people on the network could read it. Use it only on a network you trust.");
7064
+ io2.out(" - Browsers allow the microphone and camera only on localhost or HTTPS, so voice and camera will not work from a phone over plain http. Fix: friend lan https on");
7065
+ }
6801
7066
  io2.out(" - Windows may ask to allow Node.js through the firewall the first time: allow it on Private networks.");
6802
7067
  io2.out(" - Going to a name such as http://my-pc:8787 needs: friend config set allowedHosts my-pc");
6803
7068
  return 0;
@@ -6805,41 +7070,41 @@ async function lanCommand(args, io2, ifaces = networkInterfaces) {
6805
7070
  case "off":
6806
7071
  cfg.bind = "127.0.0.1";
6807
7072
  saveConfig(cfg);
6808
- show(io2, cfg.bind, cfg.port, ifaces);
7073
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
6809
7074
  io2.out("Restart Friend to apply.");
6810
7075
  return 0;
6811
7076
  case "port": {
6812
7077
  cfg.port = parsePort(rest[0]);
6813
7078
  saveConfig(cfg);
6814
- show(io2, cfg.bind, cfg.port, ifaces);
7079
+ show(io2, cfg.bind, cfg.port, ifaces, cfg.https);
6815
7080
  io2.out("Restart Friend to apply.");
6816
7081
  return 0;
6817
7082
  }
6818
7083
  default:
6819
- io2.err("Usage: friend lan status | on [--port N] | off | port N");
7084
+ io2.err("Usage: friend lan status | on [--port N] [--https] | off | port N | https on|off");
6820
7085
  return 1;
6821
7086
  }
6822
7087
  }
6823
7088
 
6824
7089
  // ../agent-cli/src/commands/voice.ts
6825
7090
  import { spawn as spawn5 } from "node:child_process";
6826
- import { existsSync as existsSync8, mkdirSync as mkdirSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync8 } from "node:fs";
6827
- import { dirname as dirname6, join as join12, resolve as resolve4 } from "node:path";
7091
+ import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "node:fs";
7092
+ import { dirname as dirname6, join as join14, resolve as resolve4 } from "node:path";
6828
7093
  import { fileURLToPath as fileURLToPath2 } from "node:url";
6829
7094
  function voicePaths(dataDir) {
6830
- const home = join12(dataDir, "voice");
6831
- const venv = join12(home, "venv");
7095
+ const home = join14(dataDir, "voice");
7096
+ const venv = join14(home, "venv");
6832
7097
  return {
6833
7098
  home,
6834
7099
  venv,
6835
- python: process.platform === "win32" ? join12(venv, "Scripts", "python.exe") : join12(venv, "bin", "python"),
6836
- models: join12(home, "models"),
6837
- marker: join12(home, "installed.json")
7100
+ python: process.platform === "win32" ? join14(venv, "Scripts", "python.exe") : join14(venv, "bin", "python"),
7101
+ models: join14(home, "models"),
7102
+ marker: join14(home, "installed.json")
6838
7103
  };
6839
7104
  }
6840
7105
  function readMarker(dataDir) {
6841
7106
  try {
6842
- return JSON.parse(readFileSync6(voicePaths(dataDir).marker, "utf8"));
7107
+ return JSON.parse(readFileSync7(voicePaths(dataDir).marker, "utf8"));
6843
7108
  } catch {
6844
7109
  return null;
6845
7110
  }
@@ -6936,8 +7201,8 @@ async function voiceSetupCommand(io2, deps = {}, args = []) {
6936
7201
  io2.err("Python 3.10 or newer is required. Install it from https://www.python.org/downloads/ and run this again.");
6937
7202
  return 1;
6938
7203
  }
6939
- mkdirSync9(p.home, { recursive: true });
6940
- if (!existsSync8(p.python)) {
7204
+ mkdirSync10(p.home, { recursive: true });
7205
+ if (!existsSync10(p.python)) {
6941
7206
  io2.out("1/3 Creating a private Python environment\u2026");
6942
7207
  const r = await run2(py.cmd, [...py.args, "-m", "venv", p.venv], { onLine });
6943
7208
  if (r.code !== 0) {
@@ -6963,7 +7228,7 @@ ${tail(dl.stdout)}`);
6963
7228
  return 1;
6964
7229
  }
6965
7230
  const marker = { python: p.python, sttModel: cfg.voice.sttModel, ttsVoice: cfg.voice.ttsVoice, at: Date.now() };
6966
- writeFileSync8(p.marker, JSON.stringify(marker, null, 2));
7231
+ writeFileSync9(p.marker, JSON.stringify(marker, null, 2));
6967
7232
  io2.out("Voice is ready. Start Friend with `friend start`, then hold Space to talk.");
6968
7233
  return 0;
6969
7234
  }
@@ -6979,7 +7244,7 @@ async function voiceStatusCommand(io2) {
6979
7244
  io2.out("Voice engine: not installed. Run: friend voice setup");
6980
7245
  return 0;
6981
7246
  }
6982
- if (!existsSync8(marker.python)) {
7247
+ if (!existsSync10(marker.python)) {
6983
7248
  io2.out("Voice engine: installed, but its Python is missing. Run: friend voice setup");
6984
7249
  return 0;
6985
7250
  }
@@ -7052,7 +7317,7 @@ async function voiceCommand(args, io2) {
7052
7317
  function findWebDir(explicit) {
7053
7318
  const here = dirname7(fileURLToPath3(import.meta.url));
7054
7319
  const candidates = [explicit, process.env.FRIEND_WEB_DIR, resolve5(here, "../../../web/dist"), resolve5(here, "web")];
7055
- return candidates.find((c) => !!c && existsSync9(join13(c, "index.html")));
7320
+ return candidates.find((c) => !!c && existsSync11(join15(c, "index.html")));
7056
7321
  }
7057
7322
  async function startFriend(opts) {
7058
7323
  const stored = loadConfig(opts.dataDir);
@@ -7086,18 +7351,20 @@ async function startFriend(opts) {
7086
7351
  }
7087
7352
  });
7088
7353
  const { port } = await app.start();
7089
- const tokenFile = join13(opts.dataDir, "local-device.token");
7090
- let token = existsSync9(tokenFile) ? readFileSync7(tokenFile, "utf8").trim() : "";
7354
+ const tokenFile = join15(opts.dataDir, "local-device.token");
7355
+ let token = existsSync11(tokenFile) ? readFileSync8(tokenFile, "utf8").trim() : "";
7091
7356
  if (!token || !app.auth.resolveDevice(token)) {
7092
7357
  token = app.auth.redeemPairCode(app.auth.createPairCode(), "this-pc");
7093
- writeFileSync9(tokenFile, token, { mode: 384 });
7358
+ writeFileSync10(tokenFile, token, { mode: 384 });
7094
7359
  }
7095
7360
  const node = startDeviceNode({
7096
- url: `ws://127.0.0.1:${port}/device`,
7361
+ url: `${app.config.https ? "wss" : "ws"}://127.0.0.1:${port}/device`,
7097
7362
  token,
7098
- roots: app.config.roots
7363
+ roots: app.config.roots,
7364
+ ...app.config.https ? { ca: readFileSync8(join15(opts.dataDir, "tls", "cert.pem"), "utf8") } : {}
7365
+ // this PC trusts only its own Friend's certificate
7099
7366
  });
7100
- const urls = urlsFor(app.config.bind, port);
7367
+ const urls = urlsFor(app.config.bind, port, void 0, app.config.https);
7101
7368
  opts.out(`Friend is running at ${urls.local}`);
7102
7369
  for (const n of urls.network) opts.out(`Also on your network: ${n}`);
7103
7370
  if (!app.auth.hasOwner()) opts.out("No owner yet. Run: friend onboard (guided), or friend setup (just the account)");
@@ -7127,8 +7394,8 @@ async function startFriend(opts) {
7127
7394
  async stop() {
7128
7395
  if (watcher) clearInterval(watcher);
7129
7396
  node.stop();
7130
- await sidecar?.stop();
7131
- await app.stop();
7397
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
7398
+ await Promise.race([Promise.allSettled([sidecar?.stop(), app.stop()]), wait(6e3)]);
7132
7399
  }
7133
7400
  };
7134
7401
  }
@@ -7322,8 +7589,8 @@ async function memoryCommand(args, io2) {
7322
7589
  }
7323
7590
 
7324
7591
  // ../agent-cli/src/commands/plugin.ts
7325
- import { existsSync as existsSync10, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
7326
- import { dirname as dirname8, join as join14, resolve as resolve7 } from "node:path";
7592
+ import { existsSync as existsSync12, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
7593
+ import { dirname as dirname8, join as join16, resolve as resolve7 } from "node:path";
7327
7594
  import { fileURLToPath as fileURLToPath4 } from "node:url";
7328
7595
  var USAGE2 = "Usage: friend plugin catalog | list | install <name|folder> [--yes] | enable <id> | disable <id> | remove <id>";
7329
7596
  function defaultCatalog() {
@@ -7337,10 +7604,10 @@ async function pluginCommand(args, io2, deps = {}) {
7337
7604
  const flags = rest.filter((a) => a.startsWith("--"));
7338
7605
  const arg = rest.find((a) => !a.startsWith("--"));
7339
7606
  if (sub === "catalog") {
7340
- const dirs = existsSync10(catalogDir) ? readdirSync3(catalogDir).filter((d) => statSync2(join14(catalogDir, d)).isDirectory()) : [];
7607
+ const dirs = existsSync12(catalogDir) ? readdirSync3(catalogDir).filter((d) => statSync2(join16(catalogDir, d)).isDirectory()) : [];
7341
7608
  if (dirs.length === 0) io2.out("The catalog is empty.");
7342
7609
  for (const d of dirs.sort()) {
7343
- const r = inspectPlugin(join14(catalogDir, d));
7610
+ const r = inspectPlugin(join16(catalogDir, d));
7344
7611
  if (!r.ok) {
7345
7612
  io2.out(`${d} (invalid: ${r.error})`);
7346
7613
  continue;
@@ -7356,7 +7623,7 @@ async function pluginCommand(args, io2, deps = {}) {
7356
7623
  }
7357
7624
  const db = openDb(io2.dataDir);
7358
7625
  try {
7359
- const plugins = new Plugins(db, join14(io2.dataDir, "plugins"));
7626
+ const plugins = new Plugins(db, join16(io2.dataDir, "plugins"));
7360
7627
  if (sub === "list") {
7361
7628
  const all = plugins.list();
7362
7629
  if (all.length === 0) io2.out("No plugins installed. See: friend plugin catalog");
@@ -7370,8 +7637,8 @@ async function pluginCommand(args, io2, deps = {}) {
7370
7637
  io2.err(USAGE2);
7371
7638
  return 1;
7372
7639
  }
7373
- const inCatalog = !/[\\/]/.test(arg) && existsSync10(join14(catalogDir, arg));
7374
- const source = inCatalog ? join14(catalogDir, arg) : existsSync10(resolve7(arg)) ? resolve7(arg) : null;
7640
+ const inCatalog = !/[\\/]/.test(arg) && existsSync12(join16(catalogDir, arg));
7641
+ const source = inCatalog ? join16(catalogDir, arg) : existsSync12(resolve7(arg)) ? resolve7(arg) : null;
7375
7642
  if (!source) {
7376
7643
  io2.err(`"${arg}" is not in the catalog and there is no such folder. See: friend plugin catalog`);
7377
7644
  return 1;
@@ -7425,8 +7692,8 @@ async function pluginCommand(args, io2, deps = {}) {
7425
7692
  }
7426
7693
 
7427
7694
  // ../agent-cli/src/commands/onboard/index.ts
7428
- import crossSpawn2 from "cross-spawn";
7429
- import { mkdirSync as mkdirSync11 } from "node:fs";
7695
+ import crossSpawn4 from "cross-spawn";
7696
+ import { mkdirSync as mkdirSync12 } from "node:fs";
7430
7697
  import { resolve as resolve8 } from "node:path";
7431
7698
 
7432
7699
  // ../agent-cli/src/commands/ollama.ts
@@ -7594,13 +7861,13 @@ async function ollamaCommand(args, io2, deps = {}) {
7594
7861
  }
7595
7862
 
7596
7863
  // ../agent-cli/src/commands/onboard/exec.ts
7597
- import crossSpawn from "cross-spawn";
7864
+ import crossSpawn3 from "cross-spawn";
7598
7865
  var CHECK_TIMEOUT_MS = 2e4;
7599
7866
  var capture = (cmd, args, opts) => new Promise((resolve10) => {
7600
7867
  let stdout = "";
7601
7868
  let child;
7602
7869
  try {
7603
- child = crossSpawn(cmd, args, { cwd: opts?.cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
7870
+ child = crossSpawn3(cmd, args, { cwd: opts?.cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
7604
7871
  } catch (e) {
7605
7872
  resolve10({ code: 127, stdout: e.message });
7606
7873
  return;
@@ -7627,7 +7894,7 @@ var capture = (cmd, args, opts) => new Promise((resolve10) => {
7627
7894
  var interactive = (cmd, args) => new Promise((resolve10) => {
7628
7895
  let child;
7629
7896
  try {
7630
- child = crossSpawn(cmd, args, { stdio: "inherit" });
7897
+ child = crossSpawn3(cmd, args, { stdio: "inherit" });
7631
7898
  } catch {
7632
7899
  resolve10(127);
7633
7900
  return;
@@ -7670,14 +7937,14 @@ async function checkDocker(d) {
7670
7937
  async function checkOllama(d) {
7671
7938
  const base = { id: "ollama", label: "Ollama", need: "optional", unlocks: "a free, private model that runs on this PC" };
7672
7939
  const v = await d.run("ollama", ["--version"]);
7673
- let running = false;
7940
+ let running2 = false;
7674
7941
  try {
7675
- running = (await d.fetch("http://127.0.0.1:11434/api/version", { signal: AbortSignal.timeout(2500) })).ok;
7942
+ running2 = (await d.fetch("http://127.0.0.1:11434/api/version", { signal: AbortSignal.timeout(2500) })).ok;
7676
7943
  } catch {
7677
- running = false;
7944
+ running2 = false;
7678
7945
  }
7679
- if (v.code === 0) return { ...base, ok: true, detail: `${first(v.stdout).replace(/^.*version is /i, "Ollama ")}${running ? ", running" : ", installed (start the Ollama app if it is not running)"}` };
7680
- if (running) return { ...base, ok: true, detail: "an Ollama server is running at 127.0.0.1:11434" };
7946
+ if (v.code === 0) return { ...base, ok: true, detail: `${first(v.stdout).replace(/^.*version is /i, "Ollama ")}${running2 ? ", running" : ", installed (start the Ollama app if it is not running)"}` };
7947
+ if (running2) return { ...base, ok: true, detail: "an Ollama server is running at 127.0.0.1:11434" };
7681
7948
  return { ...base, ok: false, detail: "not installed" };
7682
7949
  }
7683
7950
  async function checkClaude(d, command = "claude") {
@@ -7749,16 +8016,16 @@ function installPlan(id2, platform, have) {
7749
8016
  }
7750
8017
 
7751
8018
  // ../agent-cli/src/commands/onboard/state.ts
7752
- import { existsSync as existsSync11, mkdirSync as mkdirSync10, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync10 } from "node:fs";
7753
- import { dirname as dirname9, join as join15 } from "node:path";
8019
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync11 } from "node:fs";
8020
+ import { dirname as dirname9, join as join17 } from "node:path";
7754
8021
  var FEATURES = ["claude-code", "claude-api", "ollama", "voice", "sandbox"];
7755
8022
  var emptyState = () => ({ version: 1, done: {}, features: [], restart: [] });
7756
- var statePath = (dataDir) => join15(dataDir, "onboarding.json");
8023
+ var statePath = (dataDir) => join17(dataDir, "onboarding.json");
7757
8024
  function loadState(dataDir) {
7758
8025
  const f = statePath(dataDir);
7759
- if (!existsSync11(f)) return emptyState();
8026
+ if (!existsSync13(f)) return emptyState();
7760
8027
  try {
7761
- const raw = JSON.parse(readFileSync8(f, "utf8"));
8028
+ const raw = JSON.parse(readFileSync9(f, "utf8"));
7762
8029
  const done = raw.done && typeof raw.done === "object" ? Object.fromEntries(Object.entries(raw.done).filter(([, v]) => typeof v === "string")) : {};
7763
8030
  const features2 = Array.isArray(raw.features) ? raw.features.filter((x) => FEATURES.includes(x)) : [];
7764
8031
  const restart = Array.isArray(raw.restart) ? raw.restart.filter((x) => typeof x === "string") : [];
@@ -7769,9 +8036,9 @@ function loadState(dataDir) {
7769
8036
  }
7770
8037
  function saveState(dataDir, state) {
7771
8038
  const f = statePath(dataDir);
7772
- mkdirSync10(dirname9(f), { recursive: true });
8039
+ mkdirSync11(dirname9(f), { recursive: true });
7773
8040
  const tmp = `${f}.tmp`;
7774
- writeFileSync10(tmp, JSON.stringify(state, null, 2));
8041
+ writeFileSync11(tmp, JSON.stringify(state, null, 2));
7775
8042
  renameSync3(tmp, f);
7776
8043
  }
7777
8044
  function resetState(dataDir) {
@@ -7860,7 +8127,7 @@ function defaultOnboardDeps() {
7860
8127
  openUrl: async (url) => {
7861
8128
  const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
7862
8129
  try {
7863
- crossSpawn2(cmd, args, { stdio: "ignore", detached: true }).unref();
8130
+ crossSpawn4(cmd, args, { stdio: "ignore", detached: true }).unref();
7864
8131
  } catch {
7865
8132
  }
7866
8133
  },
@@ -8127,7 +8394,7 @@ async function workspace(c) {
8127
8394
  const answer = await text(c.ui, current ? "Workspace folder" : "Workspace folder (Enter to skip)", current ?? "");
8128
8395
  if (answer) {
8129
8396
  const abs = resolve8(answer);
8130
- mkdirSync11(abs, { recursive: true });
8397
+ mkdirSync12(abs, { recursive: true });
8131
8398
  cfg.roots = [abs];
8132
8399
  saveConfig(cfg);
8133
8400
  io2.out(`Workspace: ${abs}`);
@@ -8161,11 +8428,12 @@ async function network(c) {
8161
8428
  }
8162
8429
  cfg.port = port;
8163
8430
  cfg.bind = where === "lan" ? "0.0.0.0" : "127.0.0.1";
8431
+ cfg.https = where === "lan" ? await yesNo(c.ui, "Use a secure (https) address so a phone can use the microphone? Your phone will show a one-time certificate warning.", true) : false;
8164
8432
  saveConfig(cfg);
8165
- const u = urlsFor(cfg.bind, cfg.port);
8433
+ const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
8166
8434
  io2.out(`Friend will be at ${u.local}`);
8167
8435
  for (const n of u.network) io2.out(`and on your network at ${n}`);
8168
- if (where === "lan") io2.out("Note: the microphone and camera only work on this PC (or over HTTPS). Everything else works from the other devices.");
8436
+ if (where === "lan" && !cfg.https) io2.out("Note: the microphone and camera only work on this PC (or over HTTPS). Everything else works from the other devices. Later: friend lan https on");
8169
8437
  return "done";
8170
8438
  }
8171
8439
  async function look(c) {
@@ -8184,7 +8452,7 @@ async function look(c) {
8184
8452
  return "done";
8185
8453
  }
8186
8454
  function summary(cfg, c) {
8187
- const u = urlsFor(cfg.bind, cfg.port);
8455
+ const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
8188
8456
  const db = openDb(c.io.dataDir);
8189
8457
  let owner = "not created yet";
8190
8458
  try {
@@ -8214,7 +8482,7 @@ async function finish(c) {
8214
8482
  io2.out("Start it whenever you like with: friend start");
8215
8483
  return "done";
8216
8484
  }
8217
- const url = urlsFor(cfg.bind, cfg.port).local;
8485
+ const url = urlsFor(cfg.bind, cfg.port, void 0, cfg.https).local;
8218
8486
  if (await yesNo(c.ui, "Open it in your browser when it is up?", true)) setTimeout(() => void deps.openUrl(url), 2500);
8219
8487
  io2.out("Starting Friend. Press Ctrl+C to stop it.");
8220
8488
  c.state.done.finish = (/* @__PURE__ */ new Date()).toISOString();
@@ -8296,6 +8564,175 @@ async function doctorCommand(io2, partial = {}) {
8296
8564
  return requiredMissing ? 1 : 0;
8297
8565
  }
8298
8566
 
8567
+ // ../agent-cli/src/commands/daemon.ts
8568
+ import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
8569
+ import { closeSync as closeSync2, existsSync as existsSync14, mkdirSync as mkdirSync13, openSync as openSync2, readFileSync as readFileSync10, renameSync as renameSync4, rmSync as rmSync6, statSync as statSync3, writeFileSync as writeFileSync12 } from "node:fs";
8570
+ import { connect } from "node:net";
8571
+ import { join as join18 } from "node:path";
8572
+ var USAGE3 = "Usage: friend daemon start | stop | restart | status";
8573
+ var MAX_LOG_BYTES = 5 * 1024 * 1024;
8574
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8575
+ var realDeps = {
8576
+ launch(logFile) {
8577
+ const fd = openSync2(logFile, "a");
8578
+ try {
8579
+ const child = spawn6(process.execPath, [process.argv[1], "start"], { detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
8580
+ child.unref();
8581
+ return child.pid;
8582
+ } finally {
8583
+ closeSync2(fd);
8584
+ }
8585
+ },
8586
+ alive(pid) {
8587
+ try {
8588
+ process.kill(pid, 0);
8589
+ return true;
8590
+ } catch (e) {
8591
+ return e.code === "EPERM";
8592
+ }
8593
+ },
8594
+ async kill(pid) {
8595
+ if (process.platform === "win32") {
8596
+ spawnSync2("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true });
8597
+ } else {
8598
+ try {
8599
+ process.kill(pid, "SIGTERM");
8600
+ } catch {
8601
+ return;
8602
+ }
8603
+ for (let i = 0; i < 40 && realDeps.alive(pid); i++) await sleep(200);
8604
+ if (realDeps.alive(pid)) {
8605
+ try {
8606
+ process.kill(pid, "SIGKILL");
8607
+ } catch {
8608
+ }
8609
+ }
8610
+ }
8611
+ for (let i = 0; i < 25 && realDeps.alive(pid); i++) await sleep(200);
8612
+ },
8613
+ portOpen(port, withinMs) {
8614
+ return new Promise((resolve10) => {
8615
+ const s = connect({ port, host: "127.0.0.1" });
8616
+ const done = (ok) => {
8617
+ s.destroy();
8618
+ resolve10(ok);
8619
+ };
8620
+ s.setTimeout(withinMs, () => done(false));
8621
+ s.once("connect", () => done(true));
8622
+ s.once("error", () => done(false));
8623
+ });
8624
+ },
8625
+ now: Date.now
8626
+ };
8627
+ var pidFile = (dataDir) => join18(dataDir, "friend.pid");
8628
+ var logFileOf = (dataDir) => join18(dataDir, "friend.log");
8629
+ function readPid(dataDir) {
8630
+ try {
8631
+ const r = JSON.parse(readFileSync10(pidFile(dataDir), "utf8"));
8632
+ return Number.isInteger(r.pid) && r.pid > 0 ? r : null;
8633
+ } catch {
8634
+ return null;
8635
+ }
8636
+ }
8637
+ function running(dataDir, deps) {
8638
+ const r = readPid(dataDir);
8639
+ if (r && deps.alive(r.pid)) return r;
8640
+ if (r) rmSync6(pidFile(dataDir), { force: true });
8641
+ return null;
8642
+ }
8643
+ function since(ms) {
8644
+ const s = Math.max(0, Math.round(ms / 1e3));
8645
+ if (s < 90) return `${s} seconds`;
8646
+ if (s < 5400) return `${Math.round(s / 60)} minutes`;
8647
+ if (s < 129600) return `${Math.round(s / 3600)} hours`;
8648
+ return `${Math.round(s / 86400)} days`;
8649
+ }
8650
+ async function start(io2, deps) {
8651
+ const existing = running(io2.dataDir, deps);
8652
+ const cfg = loadConfig(io2.dataDir);
8653
+ const urls = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
8654
+ if (existing) {
8655
+ io2.out(`Friend is already running in the background (pid ${existing.pid}) at ${urls.local}`);
8656
+ return 0;
8657
+ }
8658
+ if (await deps.portOpen(cfg.port, 500)) {
8659
+ io2.err(`Port ${cfg.port} is already in use. Friend may be running in a terminal (stop it with Ctrl+C), or another program uses that port (friend lan port N).`);
8660
+ return 1;
8661
+ }
8662
+ mkdirSync13(io2.dataDir, { recursive: true });
8663
+ const log = logFileOf(io2.dataDir);
8664
+ try {
8665
+ if (existsSync14(log) && statSync3(log).size > MAX_LOG_BYTES) renameSync4(log, `${log}.old`);
8666
+ } catch {
8667
+ }
8668
+ const pid = deps.launch(log);
8669
+ if (!pid) {
8670
+ io2.err("Could not start Friend in the background.");
8671
+ return 1;
8672
+ }
8673
+ writeFileSync12(pidFile(io2.dataDir), JSON.stringify({ pid, startedAt: deps.now() }));
8674
+ for (let i = 0; i < 60; i++) {
8675
+ if (await deps.portOpen(cfg.port, 300)) {
8676
+ io2.out(`Friend is running in the background (pid ${pid}) at ${urls.local}`);
8677
+ for (const n of urls.network) io2.out(`Also on your network: ${n}`);
8678
+ io2.out(`Stop it with: friend daemon stop (log: ${log})`);
8679
+ return 0;
8680
+ }
8681
+ if (!deps.alive(pid)) break;
8682
+ await sleep(250);
8683
+ }
8684
+ rmSync6(pidFile(io2.dataDir), { force: true });
8685
+ io2.err(`Friend did not start. See the log: ${log}`);
8686
+ return 1;
8687
+ }
8688
+ async function stop(io2, deps) {
8689
+ const r = running(io2.dataDir, deps);
8690
+ if (!r) {
8691
+ io2.out("Friend is not running in the background.");
8692
+ return 0;
8693
+ }
8694
+ await deps.kill(r.pid);
8695
+ if (deps.alive(r.pid)) {
8696
+ io2.err(`Could not stop Friend (pid ${r.pid}). End that process yourself.`);
8697
+ return 1;
8698
+ }
8699
+ rmSync6(pidFile(io2.dataDir), { force: true });
8700
+ io2.out("Friend stopped.");
8701
+ return 0;
8702
+ }
8703
+ async function status(io2, deps) {
8704
+ const cfg = loadConfig(io2.dataDir);
8705
+ const urls = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
8706
+ const r = running(io2.dataDir, deps);
8707
+ if (r) {
8708
+ io2.out(`Friend is running in the background (pid ${r.pid}, for ${since(deps.now() - r.startedAt)}).`);
8709
+ io2.out(`Address: ${urls.local}`);
8710
+ for (const n of urls.network) io2.out(`Also on your network: ${n}`);
8711
+ io2.out(`Log: ${logFileOf(io2.dataDir)}`);
8712
+ return 0;
8713
+ }
8714
+ if (await deps.portOpen(cfg.port, 500)) io2.out(`Friend is not running in the background, but port ${cfg.port} is in use. It may be running in a terminal.`);
8715
+ else io2.out("Friend is not running. Start it in the background with: friend daemon start");
8716
+ return 1;
8717
+ }
8718
+ async function daemonCommand(args, io2, deps = realDeps) {
8719
+ switch (args[0]) {
8720
+ case "start":
8721
+ return start(io2, deps);
8722
+ case "stop":
8723
+ return stop(io2, deps);
8724
+ case "restart": {
8725
+ const code = await stop(io2, deps);
8726
+ return code === 0 ? start(io2, deps) : code;
8727
+ }
8728
+ case "status":
8729
+ return status(io2, deps);
8730
+ default:
8731
+ io2.err(USAGE3);
8732
+ return 1;
8733
+ }
8734
+ }
8735
+
8299
8736
  // ../agent-cli/src/commands/claude.ts
8300
8737
  async function check2(run2, command) {
8301
8738
  const v = await run2(command, ["--version"]);
@@ -8364,7 +8801,7 @@ async function claudeCommand(args, io2, deps = {}) {
8364
8801
  }
8365
8802
 
8366
8803
  // ../agent-cli/src/commands/setup.ts
8367
- import { mkdirSync as mkdirSync12 } from "node:fs";
8804
+ import { mkdirSync as mkdirSync14 } from "node:fs";
8368
8805
  import { resolve as resolve9 } from "node:path";
8369
8806
  async function setupCommand3(io2) {
8370
8807
  const db = openDb(io2.dataDir);
@@ -8390,7 +8827,7 @@ async function setupCommand3(io2) {
8390
8827
  const cfg = loadConfig(io2.dataDir);
8391
8828
  if (workspace2) {
8392
8829
  const abs = resolve9(workspace2);
8393
- mkdirSync12(abs, { recursive: true });
8830
+ mkdirSync14(abs, { recursive: true });
8394
8831
  cfg.roots = [abs];
8395
8832
  }
8396
8833
  if (apiKey) cfg.apiKey = apiKey;
@@ -8417,7 +8854,7 @@ async function statusCommand3(io2) {
8417
8854
  io2.out(`Data folder: ${cfg.dataDir}`);
8418
8855
  io2.out(`Owner set up: ${owner}`);
8419
8856
  io2.out(`Listens on: ${cfg.bind}:${cfg.port}`);
8420
- for (const n of urlsFor(cfg.bind, cfg.port).network) io2.out(`Network URL: ${n}`);
8857
+ for (const n of urlsFor(cfg.bind, cfg.port, void 0, cfg.https).network) io2.out(`Network URL: ${n}`);
8421
8858
  io2.out(`Public URL: ${cfg.publicUrl ?? "(none)"}`);
8422
8859
  io2.out(`Workspace: ${cfg.roots.length ? cfg.roots.join(", ") : "(none, every file action asks first)"}`);
8423
8860
  io2.out(`Model: ${cfg.model}`);
@@ -8426,12 +8863,13 @@ async function statusCommand3(io2) {
8426
8863
  }
8427
8864
 
8428
8865
  // ../agent-cli/src/cli.ts
8429
- var USAGE3 = `Usage: friend <command>
8866
+ var USAGE4 = `Usage: friend <command>
8430
8867
 
8431
8868
  onboard guided setup, step by step: checks this PC, installs what is missing, asks what you want
8432
8869
  doctor check what this PC has and what is missing (changes nothing)
8433
8870
  setup create the owner account (password, PIN, assistant name)
8434
8871
  start run the gateway and a local device node
8872
+ daemon start|stop|restart|status run Friend in the background (no terminal needed), stop it, or see if it is running
8435
8873
  pair print a one-time code to connect another PC
8436
8874
  node --url <ws-url> --code <code> [--roots a,b] [--name pc]
8437
8875
  connect this PC to a Friend gateway
@@ -8456,6 +8894,8 @@ async function run(argv, io2) {
8456
8894
  return await setupCommand3(io2);
8457
8895
  case "start":
8458
8896
  return await startCommand(io2);
8897
+ case "daemon":
8898
+ return await daemonCommand(rest, io2);
8459
8899
  case "pair":
8460
8900
  return await pairCommand(io2);
8461
8901
  case "node":
@@ -8471,7 +8911,7 @@ async function run(argv, io2) {
8471
8911
  case "version":
8472
8912
  case "--version":
8473
8913
  case "-v":
8474
- io2.out(true ? "0.1.1" : "development");
8914
+ io2.out(true ? "0.1.3" : "development");
8475
8915
  return 0;
8476
8916
  case "lan":
8477
8917
  return await lanCommand(rest, io2);
@@ -8490,7 +8930,7 @@ async function run(argv, io2) {
8490
8930
  case "config":
8491
8931
  return await configCommand(rest, io2);
8492
8932
  default:
8493
- io2.err(USAGE3);
8933
+ io2.err(USAGE4);
8494
8934
  return 1;
8495
8935
  }
8496
8936
  } catch (e) {
@@ -8551,4 +8991,5 @@ var io = {
8551
8991
  };
8552
8992
  run(process.argv.slice(2), io).then((code) => {
8553
8993
  if (code !== 0) process.exitCode = code;
8994
+ if (process.argv[2] === "start") setTimeout(() => process.exit(), 1e3).unref();
8554
8995
  });