friend-assistant 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -51,7 +51,7 @@ Every command starts with `friend`. If you run Friend from the source code, writ
51
51
 
52
52
  | Command | What it does | Use it when |
53
53
  |---|---|---|
54
- | `friend start` | Starts Friend: the web app, the assistant, the speech engine if installed, and your connectors. Prints the address (and your network address when that is on). Ctrl+C stops it. | Every day. Open the address in a browser and sign in. |
54
+ | `friend start` | Starts Friend: the web app, the assistant, the speech engine if installed, and your connectors. Prints the address (and your network address when that is on). Ctrl+C stops it. Friend's screen works in one browser window at a time: open it in a second place and that window shows "Use here", which moves Friend there and stops the first. | Every day. Open the address in a browser and sign in. |
55
55
  | `friend pair` | Prints a one-time pairing code, valid for 5 minutes, and the command to run on the other PC. | You want Friend to also work on a second PC. |
56
56
  | `friend node --url ws://HOST:8787/device --code CODE` | Run on the other PC: connects it to your Friend. Add `--roots C:/work,D:/files` for the folders it may use freely and `--name laptop` to name it. | The second step after `friend pair`. |
57
57
  | `friend devices list` | Lists the paired PCs and when each was last seen. | Checking what is connected. |
@@ -85,6 +85,10 @@ Every command starts with `friend`. If you run Friend from the source code, writ
85
85
 
86
86
  | Command | What it does | Use it when |
87
87
  |---|---|---|
88
+ | `friend daemon start` | Runs Friend in the background, with no terminal window needed. Prints the address, keeps a log in the data folder (`friend.log`). | You want Friend always available, even after you close the terminal. |
89
+ | `friend daemon stop` | Stops the background Friend and the speech engine with it. | Shutting it down. |
90
+ | `friend daemon restart` | Stops it and starts it again. | After changing a setting such as `friend lan on` or a different model. |
91
+ | `friend daemon status` | Says whether the background Friend is running, for how long, and at which address. Exit code 0 when running, 1 when not. | Checking, or in a script. |
88
92
  | `friend lan` (or `friend lan status`) | Shows what Friend listens on and the addresses to open. | Checking, or finding the address for your phone. |
89
93
  | `friend lan on` | Lets other devices on your network open Friend. Prints the addresses and the things to know (plain http, no microphone on other devices, firewall prompt). | Using Friend from a phone or another PC. |
90
94
  | `friend lan on --https` | The same, with a secure `https://` address (Friend makes its own certificate), so a phone may use the microphone and camera. | Talking to Friend from a phone. |
package/dist/BUILD.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.2",
4
- "builtAt": "2026-10-08T15:13:28.077Z"
3
+ "version": "0.1.4",
4
+ "builtAt": "2026-10-09T05:21:33.354Z"
5
5
  }
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
@@ -3619,9 +3619,9 @@ import { z as z10 } from "zod";
3619
3619
 
3620
3620
  // ../server/src/http-error.ts
3621
3621
  var HttpError = class extends Error {
3622
- constructor(status, message) {
3622
+ constructor(status2, message) {
3623
3623
  super(message);
3624
- this.status = status;
3624
+ this.status = status2;
3625
3625
  }
3626
3626
  status;
3627
3627
  };
@@ -4075,6 +4075,7 @@ function createGateway(deps) {
4075
4075
  const fwd = config.trustProxy ? String(req.headers["x-forwarded-for"] ?? "").split(",").at(-1)?.trim() : "";
4076
4076
  return fwd || req.socket.remoteAddress || "unknown";
4077
4077
  };
4078
+ let activeOwner = null;
4078
4079
  function handleClient(ws, req) {
4079
4080
  if (!originAllowed(req)) {
4080
4081
  audit.log("anonymous", "ws.origin_refused", { origin: req.headers.origin });
@@ -4083,6 +4084,19 @@ function createGateway(deps) {
4083
4084
  const token = parseCookies(req.headers.cookie)[COOKIE_NAME];
4084
4085
  const session = token ? auth.resolveSession(token) : null;
4085
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
+ }
4086
4100
  const conn = {
4087
4101
  ip: clientIp(req),
4088
4102
  send: (m) => {
@@ -4093,6 +4107,7 @@ function createGateway(deps) {
4093
4107
  const dispose = deps.onClientConnect?.(session, conn);
4094
4108
  ws.on("close", () => dispose?.());
4095
4109
  ws.on("message", (data) => {
4110
+ if (tab && activeOwner?.ws !== ws) return;
4096
4111
  let json;
4097
4112
  try {
4098
4113
  json = JSON.parse(String(data));
@@ -6342,11 +6357,10 @@ async function createApp(opts) {
6342
6357
  camera.cancelAll(broadcast);
6343
6358
  agent.cancel();
6344
6359
  approvals.denyAll("Shutting down");
6345
- await gateway.close();
6346
- await provider.close?.().catch(() => {
6347
- });
6348
- await hub.close().catch(() => {
6349
- });
6360
+ await Promise.all([gateway.close().catch(() => {
6361
+ }), provider.close?.().catch(() => {
6362
+ }), hub.close().catch(() => {
6363
+ })]);
6350
6364
  db.close();
6351
6365
  }
6352
6366
  };
@@ -6897,7 +6911,7 @@ function startDeviceNode(opts) {
6897
6911
  state = s;
6898
6912
  opts.onState?.(s);
6899
6913
  };
6900
- function connect() {
6914
+ function connect2() {
6901
6915
  if (stopped) return;
6902
6916
  setState("connecting");
6903
6917
  const sock = new WebSocket2(opts.url, { headers: { authorization: `Bearer ${opts.token}` }, ...opts.ca ? { ca: opts.ca } : {} });
@@ -6945,10 +6959,10 @@ function startDeviceNode(opts) {
6945
6959
  if (opts.reconnect === false) return;
6946
6960
  const delay = Math.min(base * 2 ** attempt, 1e4);
6947
6961
  attempt += 1;
6948
- timer = setTimeout(connect, delay);
6962
+ timer = setTimeout(connect2, delay);
6949
6963
  });
6950
6964
  }
6951
- connect();
6965
+ connect2();
6952
6966
  return {
6953
6967
  connected,
6954
6968
  state: () => state,
@@ -7380,8 +7394,8 @@ async function startFriend(opts) {
7380
7394
  async stop() {
7381
7395
  if (watcher) clearInterval(watcher);
7382
7396
  node.stop();
7383
- await sidecar?.stop();
7384
- 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)]);
7385
7399
  }
7386
7400
  };
7387
7401
  }
@@ -7923,14 +7937,14 @@ async function checkDocker(d) {
7923
7937
  async function checkOllama(d) {
7924
7938
  const base = { id: "ollama", label: "Ollama", need: "optional", unlocks: "a free, private model that runs on this PC" };
7925
7939
  const v = await d.run("ollama", ["--version"]);
7926
- let running = false;
7940
+ let running2 = false;
7927
7941
  try {
7928
- 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;
7929
7943
  } catch {
7930
- running = false;
7944
+ running2 = false;
7931
7945
  }
7932
- 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)"}` };
7933
- 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" };
7934
7948
  return { ...base, ok: false, detail: "not installed" };
7935
7949
  }
7936
7950
  async function checkClaude(d, command = "claude") {
@@ -8550,6 +8564,175 @@ async function doctorCommand(io2, partial = {}) {
8550
8564
  return requiredMissing ? 1 : 0;
8551
8565
  }
8552
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
+
8553
8736
  // ../agent-cli/src/commands/claude.ts
8554
8737
  async function check2(run2, command) {
8555
8738
  const v = await run2(command, ["--version"]);
@@ -8618,7 +8801,7 @@ async function claudeCommand(args, io2, deps = {}) {
8618
8801
  }
8619
8802
 
8620
8803
  // ../agent-cli/src/commands/setup.ts
8621
- import { mkdirSync as mkdirSync13 } from "node:fs";
8804
+ import { mkdirSync as mkdirSync14 } from "node:fs";
8622
8805
  import { resolve as resolve9 } from "node:path";
8623
8806
  async function setupCommand3(io2) {
8624
8807
  const db = openDb(io2.dataDir);
@@ -8644,7 +8827,7 @@ async function setupCommand3(io2) {
8644
8827
  const cfg = loadConfig(io2.dataDir);
8645
8828
  if (workspace2) {
8646
8829
  const abs = resolve9(workspace2);
8647
- mkdirSync13(abs, { recursive: true });
8830
+ mkdirSync14(abs, { recursive: true });
8648
8831
  cfg.roots = [abs];
8649
8832
  }
8650
8833
  if (apiKey) cfg.apiKey = apiKey;
@@ -8680,17 +8863,18 @@ async function statusCommand3(io2) {
8680
8863
  }
8681
8864
 
8682
8865
  // ../agent-cli/src/cli.ts
8683
- var USAGE3 = `Usage: friend <command>
8866
+ var USAGE4 = `Usage: friend <command>
8684
8867
 
8685
8868
  onboard guided setup, step by step: checks this PC, installs what is missing, asks what you want
8686
8869
  doctor check what this PC has and what is missing (changes nothing)
8687
8870
  setup create the owner account (password, PIN, assistant name)
8688
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
8689
8873
  pair print a one-time code to connect another PC
8690
8874
  node --url <ws-url> --code <code> [--roots a,b] [--name pc]
8691
8875
  connect this PC to a Friend gateway
8692
8876
  ollama setup|status use a local model through Ollama (no API key needed)
8693
- lan status|on|off|port N let other devices on your network open Friend, or change the port (restart to apply)
8877
+ lan status|on|off|port N|https on|off let other devices on your network open Friend, change the port, or turn on the secure (https) address a phone needs for the microphone (restart to apply)
8694
8878
  claude setup|status use your Claude subscription through Claude Code (no API key needed)
8695
8879
  voice setup|list|status|forget install local speech and choose a female or male voice; check it; forget deletes your saved voice
8696
8880
  memory list|forget <n>|clear see and delete the notes Friend keeps about you
@@ -8710,6 +8894,8 @@ async function run(argv, io2) {
8710
8894
  return await setupCommand3(io2);
8711
8895
  case "start":
8712
8896
  return await startCommand(io2);
8897
+ case "daemon":
8898
+ return await daemonCommand(rest, io2);
8713
8899
  case "pair":
8714
8900
  return await pairCommand(io2);
8715
8901
  case "node":
@@ -8722,10 +8908,15 @@ async function run(argv, io2) {
8722
8908
  return await onboardCommand(rest, io2);
8723
8909
  case "doctor":
8724
8910
  return await doctorCommand(io2);
8911
+ case "help":
8912
+ case "--help":
8913
+ case "-h":
8914
+ io2.out(USAGE4);
8915
+ return 0;
8725
8916
  case "version":
8726
8917
  case "--version":
8727
8918
  case "-v":
8728
- io2.out(true ? "0.1.2" : "development");
8919
+ io2.out(true ? "0.1.4" : "development");
8729
8920
  return 0;
8730
8921
  case "lan":
8731
8922
  return await lanCommand(rest, io2);
@@ -8744,7 +8935,7 @@ async function run(argv, io2) {
8744
8935
  case "config":
8745
8936
  return await configCommand(rest, io2);
8746
8937
  default:
8747
- io2.err(USAGE3);
8938
+ io2.err(USAGE4);
8748
8939
  return 1;
8749
8940
  }
8750
8941
  } catch (e) {
@@ -8805,4 +8996,5 @@ var io = {
8805
8996
  };
8806
8997
  run(process.argv.slice(2), io).then((code) => {
8807
8998
  if (code !== 0) process.exitCode = code;
8999
+ if (process.argv[2] === "start") setTimeout(() => process.exit(), 1e3).unref();
8808
9000
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A self-hosted personal AI assistant for your own PC: a full-screen stage, voice, tools with your approval, and your Claude subscription or a local model.",
5
5
  "keywords": ["ai", "assistant", "jarvis", "voice", "claude", "ollama", "mcp", "self-hosted"],
6
6
  "license": "UNLICENSED",