owlmem 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +34 -1
  2. package/owlmem.mjs +443 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -19,11 +19,44 @@ changes is backed up first (`<file>.bak-owlmem-<time>`). It also adds a short "T
19
19
  | `--yes` | don't ask (when stdin isn't a terminal — an AI running it — it never asks) |
20
20
  | `--only claude-code,cursor` | only these apps (`claude-code`, `codex`, `cursor`, `vscode`, `vscode-insiders`, `windsurf`, `gemini`, `claude-desktop`) |
21
21
  | `--no-rules` | leave the instruction files alone |
22
- | `--uninstall` | remove the OwlMem entries and rules block again |
22
+ | `--wake` / `--no-wake` | turn on auto-wake (below) without asking / don't ask about it |
23
+ | `--uninstall` | remove the OwlMem entries and rules block again, and auto-wake |
23
24
 
24
25
  Signing in is done by each app (standard MCP OAuth): the first time it uses OwlMem it opens the owlmem.com
25
26
  page, where you sign in and click **Allow**. AI agents can read the setup guide at https://mcp.owlmem.com.
26
27
 
28
+ ## Auto-wake
29
+
30
+ An AI on your computer normally sees a task only when someone opens it. Auto-wake starts it as soon as it gets one:
31
+ a small background program long-polls OwlMem and runs that AI's command line unattended — Claude Code with
32
+ `claude -p`, Codex with `codex exec` — with the task as the prompt. The AI marks the task done or declined as usual.
33
+
34
+ ```sh
35
+ npx -y owlmem install --wake # or answer "y" when install asks; --yes alone leaves it off
36
+ npx -y owlmem wake enable # on its own (options: --boot, --max N, --timeout MIN, --again)
37
+ npx -y owlmem wake status
38
+ npx -y owlmem wake disable # also done by npx -y owlmem uninstall
39
+ ```
40
+
41
+ - **Pairing.** `wake enable` prints a link: open it, check the code matches, and pick which AI each app on this
42
+ computer is. Until then nothing starts. The computer is identified by its device key (next to `config.json`).
43
+ - **Runs as you.** A systemd user service (Linux), a LaunchAgent (macOS) or a logon task (Windows) — this user
44
+ only, no admin rights. `--boot` (Linux) turns on lingering so it runs from boot, not just from login.
45
+ - **One program per computer** for every paired AI. One task at a time per AI, at most 2 at once (`--max`),
46
+ 30 minutes per task (`--timeout`); a task that runs out of time is stopped and marked declined for a person.
47
+ A task sent to every agent wakes just one.
48
+ - **Working folder.** A line like `cwd: ~/Projects/app` in the task picks the folder. In a Git repository the AI
49
+ gets its own worktree and branch (`owlmem/task-<id>`), commits there and doesn't merge — the result names the
50
+ branch. Other folders take one task at a time; with no folder it works in an empty one.
51
+ - **Safety.** The AI uses its own OwlMem connection and the permissions it always has: no `--dangerously-*` flags.
52
+ Claude Code is only additionally allowed the two OwlMem tools it needs to report back (`kb_task_update`,
53
+ `kb_inbox`). Anything that needs a person — an approval, a sign-in, a code — it declines, saying what to do.
54
+ It is told never to put secrets in its result.
55
+ - **Turning it off.** Each AI has an auto-wake switch in the console (AI agents → Agents), where you can also remove
56
+ the computer. When the computer is removed, the AI is disconnected or the account is gone, the program gets a 401,
57
+ removes its own autostart entry and exits. The command for each app is in `~/.config/owlmem/wake/wake.json`;
58
+ logs and worktrees stay in that folder after uninstalling.
59
+
27
60
  ## Vault
28
61
 
29
62
  ```sh
package/owlmem.mjs CHANGED
@@ -5,9 +5,10 @@
5
5
  // server only ever sees ciphertext. Your space key comes from the recovery code shown once when the vault
6
6
  // was set up in the console; `owlmem vault unlock` stores it on this machine (0600).
7
7
  //
8
- // owlmem install [--dry-run] [--yes] [--only a,b] [--no-rules] [--uninstall]
8
+ // owlmem install [--dry-run] [--yes] [--only a,b] [--no-rules] [--wake] [--uninstall]
9
9
  // connect Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and
10
10
  // Claude Desktop on this computer to OwlMem (npx -y owlmem install)
11
+ // owlmem wake enable|disable|status auto-wake: start the AI on this computer when it gets a task (runs from login)
11
12
  // owlmem login save this agent's token (from the console)
12
13
  // owlmem vault unlock paste the recovery code once per machine
13
14
  // owlmem vault ls
@@ -32,12 +33,13 @@
32
33
  // device key there, so standing grants don't apply. Writing, deleting and exporting always need approval.
33
34
 
34
35
  import { webcrypto as crypto } from "node:crypto";
35
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync } from "node:fs";
36
- import { homedir, hostname } from "node:os";
37
- import { join, dirname } from "node:path";
36
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, rmSync, statSync, realpathSync, openSync, closeSync, appendFileSync } from "node:fs";
37
+ import { homedir, hostname, userInfo } from "node:os";
38
+ import { join, dirname, basename, relative, isAbsolute } from "node:path";
39
+ import { fileURLToPath } from "node:url";
38
40
  import { spawn, spawnSync } from "node:child_process";
39
41
 
40
- export const VERSION = "0.6.0";
42
+ export const VERSION = "0.7.0";
41
43
  const BASE = (process.env.OWLMEM_URL || "https://owlmem.com").replace(/\/$/, "");
42
44
  const DIR = process.env.OWLMEM_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "owlmem");
43
45
  const enc = new TextEncoder(), dec = new TextDecoder();
@@ -200,10 +202,11 @@ export async function deviceFingerprint(spki) {
200
202
  return h.match(/.{4}/g).join("-");
201
203
  }
202
204
  // mode: "run" = the secret goes into a command; "view" = it is shown (stdout / the AI's conversation); "other" = anything else
203
- async function signHeaders(method, path, mode) {
205
+ // url: the full request URL (only its path is signed)
206
+ async function signHeaders(method, url, mode) {
204
207
  const d = await device();
205
208
  const ts = String(Math.floor(Date.now() / 1000)), nonce = b64url(crypto.getRandomValues(new Uint8Array(18)));
206
- const msg = ["owlmem-device-v1", method.toUpperCase(), new URL(BASE + "/v1/vault/" + path).pathname, mode, ts, nonce].join("\n");
209
+ const msg = ["owlmem-device-v1", method.toUpperCase(), new URL(url).pathname, mode, ts, nonce].join("\n");
207
210
  const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, d.key, enc.encode(msg)));
208
211
  return { "X-OwlMem-Device": d.spki, "X-OwlMem-Device-Name": (process.env.OWLMEM_DEVICE_NAME || hostname()).replace(/[^\w .@()-]/g, "").slice(0, 60),
209
212
  "X-OwlMem-Mode": mode, "X-OwlMem-Time": ts, "X-OwlMem-Nonce": nonce, "X-OwlMem-Sig": b64url(sig) };
@@ -213,7 +216,7 @@ async function signHeaders(method, path, mode) {
213
216
  async function call(method, path, body, mode = "other") {
214
217
  let r;
215
218
  try {
216
- const sh = await signHeaders(method, path, mode).catch(() => ({})); // can't write the device key: no standing grants, everything else still works
219
+ const sh = await signHeaders(method, BASE + "/v1/vault/" + path, mode).catch(() => ({})); // can't write the device key: no standing grants, everything else still works
217
220
  r = await fetch(BASE + "/v1/vault/" + path, { method, headers: { Authorization: "Bearer " + token(), "Content-Type": "application/json", "User-Agent": "owlmem-cli/" + VERSION, ...sh },
218
221
  body: body === undefined ? undefined : JSON.stringify(body) });
219
222
  } catch (e) { throw new Fail("Can't reach OwlMem (" + BASE + "): " + e.message); }
@@ -703,13 +706,15 @@ const LOGIN = {
703
706
  };
704
707
 
705
708
  async function install(args) {
706
- const opts = { dryRun: false, yes: false, noRules: false, uninstall: false, only: null };
709
+ const opts = { dryRun: false, yes: false, noRules: false, uninstall: false, only: null, wake: null };
707
710
  for (let i = 0; i < args.length; i++) {
708
711
  const a = args[i];
709
712
  if (a === "--dry-run") opts.dryRun = true;
710
713
  else if (a === "--yes" || a === "-y") opts.yes = true;
711
714
  else if (a === "--no-rules") opts.noRules = true;
712
715
  else if (a === "--uninstall") opts.uninstall = true;
716
+ else if (a === "--wake") opts.wake = true;
717
+ else if (a === "--no-wake") opts.wake = false;
713
718
  else if (a === "--only" || a.startsWith("--only=")) {
714
719
  const v = a.includes("=") ? a.slice(7) : args[++i];
715
720
  opts.only = String(v || "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -729,6 +734,7 @@ async function install(args) {
729
734
  out(items.some((r) => r.state === "ok") || opts.uninstall ? "Nothing to change." : "No supported AI apps found. Manual setup for every app: https://owlmem.com/docs");
730
735
  if (!opts.uninstall && items.some((r) => r.state === "ok")) nextSteps(items, out);
731
736
  process.exitCode = items.some((r) => r.state === "fail") ? 1 : 0;
737
+ if (!opts.dryRun) await installWake(opts, items, out);
732
738
  return;
733
739
  }
734
740
  if (opts.dryRun) { out(`Dry run: ${changes.length} app(s) would change. Run again without --dry-run to apply.`); return; }
@@ -750,9 +756,24 @@ async function install(args) {
750
756
  if (ans.startsWith("y")) await new Promise((res) => spawn("codex", ["mcp", "login", "owlmem"], { stdio: "inherit", shell: process.platform === "win32" }).on("exit", res).on("error", res));
751
757
  }
752
758
  }
759
+ await installWake(opts, items, out);
753
760
  if (items.some((r) => r.state === "fail")) process.exitCode = 1;
754
761
  }
755
762
 
763
+ // `owlmem install` asks once whether to turn on auto-wake (--wake: yes without asking; --yes alone: no). `uninstall` removes it.
764
+ async function installWake(opts, items, out) {
765
+ try {
766
+ if (opts.uninstall) { await wakeDisable(true); return; }
767
+ if (opts.wake === false || loadWake()) return;
768
+ if (!items.some((r) => (r.id === "claude-code" || r.id === "codex") && ["ok", "add", "update"].includes(r.state))) { if (opts.wake) out("Auto-wake: needs Claude Code or Codex on this computer."); return; }
769
+ let on = opts.wake === true;
770
+ if (!on && !opts.yes && process.stdin.isTTY && process.stdout.isTTY)
771
+ on = (await ask("\nStart your AI automatically when it gets a task (auto-wake, runs in the background)? [y/N] ", false)).toLowerCase().startsWith("y");
772
+ if (!on) { if (!opts.yes) out("Auto-wake is off. Turn it on any time: npx -y owlmem wake enable"); return; }
773
+ out(); await wakeEnable([]);
774
+ } catch (e) { out("Auto-wake: " + (e instanceof Fail ? e.message : e?.message || e)); process.exitCode = 1; }
775
+ }
776
+
756
777
  function nextSteps(items, out) {
757
778
  const live = items.filter((r) => ["ok", "add", "update"].includes(r.state));
758
779
  if (!live.length) return;
@@ -765,6 +786,415 @@ function nextSteps(items, out) {
765
786
  out("Already-running sessions don't see new MCP servers: start a new session, then ask your AI to run kb_search.");
766
787
  }
767
788
 
789
+ // ── auto-wake: start the AI on this computer when it gets a task ──
790
+ // One background program per user per computer (`owlmem wake run`, started at login by systemd --user / a LaunchAgent / a
791
+ // logon task) long-polls OwlMem. When an agent paired with this computer gets a task, it runs that agent's command line
792
+ // unattended (`claude -p`, `codex exec`) with the task as the prompt. The woken AI uses its own OwlMem connection (the one
793
+ // `owlmem install` set up), so it acts as itself. It gets no extra permissions: no --dangerously-* flags; anything that
794
+ // needs a person, it declines with what the person has to do.
795
+ // This computer is identified by its device key (device.json); the pairing is approved in the console, where each AI has an
796
+ // auto-wake switch and the computer can be removed. If OwlMem answers 401 (computer removed, AI disconnected, account gone)
797
+ // the program removes its own autostart entry and exits.
798
+ export const WAKE_CLIENTS = {
799
+ "claude-code": { name: "Claude Code", bin: "claude",
800
+ // -p runs one prompt and exits; it may only use the two OwlMem tools it needs to report back, beyond what your own settings allow
801
+ command: ["claude", "-p", "--allowedTools", "mcp__owlmem__kb_task_update,mcp__owlmem__kb_inbox"], prompt: "stdin" },
802
+ codex: { name: "Codex", bin: "codex", command: ["codex", "exec", "--skip-git-repo-check", "-"], prompt: "stdin" },
803
+ };
804
+ export const WAKE_DEFAULTS = { max_parallel: 2, timeout_min: 30 };
805
+ const WAKE_UNIT = "owlmem-wake.service", WAKE_LABEL = "com.owlmem.wake", WAKE_TASK = "OwlMem Wake", WAKE_RUNKEY = "OwlMemWake";
806
+ const wakeDir = (dir = DIR) => join(dir, "wake");
807
+ const wakeFile = (dir = DIR) => join(wakeDir(dir), "wake.json");
808
+ export function loadWake(dir = DIR) { try { return JSON.parse(readFileSync(wakeFile(dir), "utf8")); } catch { return null; } }
809
+ function saveWake(dir, c) { saveFile(wakeFile(dir), JSON.stringify(c, null, 2) + "\n"); }
810
+
811
+ // Where the autostart entry goes and what it says. o: {platform, home, dir, node, script, path, uid, user, boot}
812
+ const xml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
813
+ const unitq = (s) => '"' + String(s).replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
814
+ export function autostart(o) {
815
+ const p = o.platform, args = [o.node, o.script, "wake", "run"];
816
+ if (p === "linux") {
817
+ const file = join(o.xdg || join(o.home, ".config"), "systemd", "user", WAKE_UNIT);
818
+ const text = `[Unit]
819
+ Description=OwlMem auto-wake (starts your AI when it gets a task)
820
+ Documentation=https://owlmem.com/docs#wake
821
+ After=network-online.target
822
+
823
+ [Service]
824
+ Type=simple
825
+ ExecStart=${args.map(unitq).join(" ")}
826
+ Environment=${unitq("OWLMEM_HOME=" + o.dir)}
827
+ Environment=${unitq("PATH=" + o.path)}
828
+ Restart=on-failure
829
+ RestartSec=30
830
+
831
+ [Install]
832
+ WantedBy=default.target
833
+ `;
834
+ const sc = (...a) => ["systemctl", "--user", ...a];
835
+ return { kind: "systemd", file, files: [{ file, text }],
836
+ start: [{ argv: sc("daemon-reload") }, { argv: sc("enable", "--now", WAKE_UNIT) },
837
+ ...(o.boot ? [{ argv: ["loginctl", "enable-linger", o.user], ignoreFail: true, note: "boot" }] : [])],
838
+ stop: [{ argv: sc("disable", "--now", WAKE_UNIT), ignoreFail: true }],
839
+ selfStop: [{ argv: sc("disable", WAKE_UNIT), ignoreFail: true }], // from inside the service: don't stop ourselves mid-cleanup
840
+ after: [{ argv: sc("daemon-reload"), ignoreFail: true }] };
841
+ }
842
+ if (p === "darwin") {
843
+ const file = join(o.home, "Library", "LaunchAgents", WAKE_LABEL + ".plist");
844
+ const log = join(wakeDir(o.dir), "wake.out.log");
845
+ const text = `<?xml version="1.0" encoding="UTF-8"?>
846
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
847
+ <plist version="1.0">
848
+ <dict>
849
+ <key>Label</key><string>${WAKE_LABEL}</string>
850
+ <key>ProgramArguments</key>
851
+ <array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array>
852
+ <key>EnvironmentVariables</key>
853
+ <dict><key>OWLMEM_HOME</key><string>${xml(o.dir)}</string><key>PATH</key><string>${xml(o.path)}</string></dict>
854
+ <key>RunAtLoad</key><true/>
855
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
856
+ <key>ThrottleInterval</key><integer>30</integer>
857
+ <key>StandardOutPath</key><string>${xml(log)}</string>
858
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
859
+ </dict>
860
+ </plist>
861
+ `;
862
+ return { kind: "launchd", file, files: [{ file, text }],
863
+ start: [{ argv: ["launchctl", "bootstrap", "gui/" + o.uid, file], fallback: { argv: ["launchctl", "load", "-w", file] } }],
864
+ stop: [{ argv: ["launchctl", "bootout", "gui/" + o.uid + "/" + WAKE_LABEL], ignoreFail: true }],
865
+ selfStop: [], after: [] };
866
+ }
867
+ if (p === "win32") {
868
+ const vbs = join(wakeDir(o.dir), "owlmem-wake.vbs");
869
+ const vq = (s) => '""' + String(s).replace(/"/g, '""""') + '""';
870
+ const text = `' OwlMem auto-wake: runs "owlmem wake run" without a console window (started at logon)\r
871
+ Set sh = CreateObject("WScript.Shell")\r
872
+ sh.Environment("PROCESS")("OWLMEM_HOME") = "${String(o.dir).replace(/"/g, '""')}"\r
873
+ sh.Run "${args.map(vq).join(" ")}", 0, False\r
874
+ `;
875
+ const tr = `wscript.exe "${vbs}"`;
876
+ const runKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
877
+ return { kind: "schtasks", file: vbs, files: [{ file: vbs, text }],
878
+ start: [{ argv: ["schtasks", "/Create", "/TN", WAKE_TASK, "/SC", "ONLOGON", "/TR", tr, "/RL", "LIMITED", "/F"],
879
+ fallback: { argv: ["reg", "add", runKey, "/v", WAKE_RUNKEY, "/t", "REG_SZ", "/d", tr, "/f"] } },
880
+ { argv: ["wscript.exe", vbs], ignoreFail: true, detach: true }],
881
+ stop: [{ argv: ["schtasks", "/End", "/TN", WAKE_TASK], ignoreFail: true }, { argv: ["schtasks", "/Delete", "/TN", WAKE_TASK, "/F"], ignoreFail: true },
882
+ { argv: ["reg", "delete", runKey, "/v", WAKE_RUNKEY, "/f"], ignoreFail: true }],
883
+ selfStop: [{ argv: ["schtasks", "/Delete", "/TN", WAKE_TASK, "/F"], ignoreFail: true }, { argv: ["reg", "delete", runKey, "/v", WAKE_RUNKEY, "/f"], ignoreFail: true }],
884
+ after: [] };
885
+ }
886
+ throw new Fail("Auto-wake isn't supported on " + p + ".");
887
+ }
888
+ // The autostart entry outlives this run: under Nix (and similar) process.execPath is a versioned store path that
889
+ // disappears after the next system upgrade, so prefer a stable path that resolves to the same node binary.
890
+ export function stableNode(execPath = process.execPath, candidates = ["/run/current-system/sw/bin/node", join(home(), ".nix-profile", "bin", "node"), "/usr/local/bin/node", "/usr/bin/node", onPath("node")]) {
891
+ let real; try { real = realpathSync(execPath); } catch { return execPath; }
892
+ for (const c of candidates) { try { if (c && c !== execPath && realpathSync(c) === real) return c; } catch {} }
893
+ return execPath;
894
+ }
895
+ function autostartOpts(dir = DIR, extra = {}) {
896
+ let uid = "", user = "";
897
+ try { const u = userInfo(); uid = String(u.uid); user = u.username; } catch {}
898
+ return { platform: platform(), home: home(), xdg: process.env.XDG_CONFIG_HOME, dir, node: stableNode(), script: join(wakeDir(dir), "owlmem.mjs"),
899
+ path: process.env.PATH || "", uid, user, ...extra };
900
+ }
901
+ // run(argv, op) → {ok, out}; op.detach: start it and don't wait (the Windows launcher)
902
+ function wakeRunCmd(argv, op = {}) {
903
+ if (!op.detach) return runCmd(argv);
904
+ try { spawn(argv[0], argv.slice(1), { stdio: "ignore", detached: true }).on("error", () => {}).unref(); return { ok: true, out: "" }; } catch (e) { return { ok: false, out: e.message }; }
905
+ }
906
+ function runOps(ops, run) {
907
+ const out = [];
908
+ for (const op of ops) {
909
+ let r = run(op.argv, op);
910
+ if (!r.ok && op.fallback) r = run(op.fallback.argv);
911
+ out.push({ argv: op.argv, ...r });
912
+ if (!r.ok && !op.ignoreFail) throw new Fail("`" + op.argv.join(" ") + "` failed: " + (r.out || "").split("\n").slice(-2).join(" "));
913
+ }
914
+ return out;
915
+ }
916
+ export function installAutostart(o, run = wakeRunCmd) {
917
+ const a = autostart(o);
918
+ for (const f of a.files) { mkdirSync(dirname(f.file), { recursive: true }); writeFileSync(f.file, f.text); }
919
+ return { ...a, ran: runOps(a.start, run) };
920
+ }
921
+ // Remove the autostart entry. self: called by the running program itself (after a 401), so it doesn't stop its own service first.
922
+ export function removeAutostart(o, run = wakeRunCmd, self = false) {
923
+ const a = autostart(o);
924
+ const ran = runOps(self ? a.selfStop : a.stop, run);
925
+ for (const f of a.files) rmSync(f.file, { force: true });
926
+ ran.push(...runOps(a.after, run));
927
+ return { ...a, ran };
928
+ }
929
+ // Everything local except the work itself: autostart entry, wake.json, the program copy. Worktrees and logs stay (they hold the AI's work).
930
+ export function wakeUninstall(o, run = wakeRunCmd, self = false) {
931
+ const r = removeAutostart(o, run, self);
932
+ for (const f of ["wake.json", "owlmem.mjs", "owlmem-wake.vbs"]) rmSync(join(wakeDir(o.dir), f), { force: true });
933
+ return r;
934
+ }
935
+
936
+ // ── a task's working directory ──
937
+ // A task can name one with a line like "cwd: ~/Projects/app" (also dir:, workdir:, 目录:). Git repository → a separate
938
+ // worktree on its own branch (owlmem/task-<id>), so two tasks in one project don't collide; the AI commits there and doesn't merge.
939
+ // Plain directory → one task at a time in it. No directory → an empty folder of its own (OwlMem-only work).
940
+ const DIR_RE = /^[ \t>*-]*(?:cwd|dir|directory|workdir|working directory|目录|工作目录)[ \t]*[::=][ \t]*`?([^`\r\n]+?)`?[ \t]*$/im;
941
+ export function taskDir(body, h = home()) {
942
+ const m = DIR_RE.exec(String(body || ""));
943
+ if (!m) return null;
944
+ let d = m[1].trim().replace(/^["']|["']$/g, "");
945
+ if (d === "~" || d.startsWith("~/") || d.startsWith("~\\")) d = join(h, d.slice(2));
946
+ return isAbsolute(d) ? d : null;
947
+ }
948
+ function gitRun(args) { const r = spawnSync("git", args, { encoding: "utf8", timeout: 60000 }); return { ok: r.status === 0, out: ((r.stdout || "") + (r.stderr || "")).trim() }; }
949
+ export function planWorkdir(task, h = home(), git = gitRun) {
950
+ const d = taskDir(task.body, h);
951
+ if (!d) return { kind: "scratch" };
952
+ let st = null; try { st = statSync(d); } catch {}
953
+ if (!st || !st.isDirectory()) return { kind: "missing", dir: d };
954
+ const real = realpathSync(d);
955
+ const top = git(["-C", real, "rev-parse", "--show-toplevel"]);
956
+ if (top.ok && top.out) return { kind: "worktree", dir: real, top: realpathSync(top.out.split("\n")[0].trim()) };
957
+ return { kind: "dir", dir: real, lock: real };
958
+ }
959
+ export function prepareWorkdir(task, plan, dir = DIR, git = gitRun) {
960
+ if (plan.kind === "missing") throw new Fail("the task's directory doesn't exist on this computer: " + plan.dir);
961
+ if (plan.kind === "scratch") { const cwd = join(wakeDir(dir), "scratch", "task-" + task.id); mkdirSync(cwd, { recursive: true }); return { cwd }; }
962
+ if (plan.kind === "dir") return { cwd: plan.dir };
963
+ const branch = "owlmem/task-" + task.id, wt = join(wakeDir(dir), "worktrees", basename(plan.top) + "-task-" + task.id);
964
+ if (!existsSync(wt)) {
965
+ mkdirSync(dirname(wt), { recursive: true });
966
+ let r = git(["-C", plan.top, "worktree", "add", "-b", branch, wt, "HEAD"]);
967
+ if (!r.ok && /already exists/i.test(r.out)) r = git(["-C", plan.top, "worktree", "add", wt, branch]);
968
+ if (!r.ok) throw new Fail("couldn't make a git worktree in " + plan.top + ": " + r.out.split("\n").slice(-1)[0]);
969
+ }
970
+ const rel = relative(plan.top, plan.dir);
971
+ return { cwd: rel ? join(wt, rel) : wt, worktree: wt, branch, repo: plan.top };
972
+ }
973
+
974
+ export function wakePrompt(task, place, o = {}) {
975
+ const id = task.id, mins = o.timeout_min || WAKE_DEFAULTS.timeout_min;
976
+ const where = place.worktree
977
+ ? `Work in ${place.cwd}. It is a separate git worktree of ${place.repo} on the branch ${place.branch}, made for this task so it doesn't collide with other work. Commit your changes on this branch only. Don't merge, rebase or push other branches, and don't touch ${place.repo} itself. Put the branch name in your result so a person can review and merge it.`
978
+ : place.scratch ? `There is no project directory for this task: you are in an empty folder (${place.cwd}). Use the OwlMem tools.`
979
+ : `Work in ${place.cwd}. Other tasks wait until you finish here.`;
980
+ return `OwlMem auto-wake started you on ${o.machine || "this computer"} to handle one task. Nobody is watching this session and nobody can answer questions.
981
+
982
+ Task #${id}, for ${task.agent}${task.broadcast ? " (sent to every agent; it is yours once you take it)" : ""}, from ${task.from}:
983
+ Title: ${task.title}
984
+
985
+ ${task.body || "(no details)"}
986
+
987
+ Rules:
988
+ 1. First call kb_task_update with id=${id} and status="doing". If the OwlMem tools aren't available, or OwlMem says the task isn't yours or is already taken, stop there and do nothing else.
989
+ 2. ${where}
990
+ 3. When you're finished, call kb_task_update with id=${id}, status="done" and note = the result (what you did, where it is).
991
+ 4. If you can't do it — including anything that needs a person to approve, sign in, enter a code or confirm — call kb_task_update with id=${id}, status="declined" and note = what a person needs to do. Don't try to get around a permission you don't have.
992
+ 5. Never put passwords, tokens, keys or other secrets in the note or in any message.
993
+ 6. You have ${mins} minutes. After that this session is stopped and the task is handed to a person.
994
+ `;
995
+ }
996
+
997
+ // ── running a task ──
998
+ export async function runTask(task, cfg, o = {}) {
999
+ const dir = o.dir || DIR, cl = (cfg.clients || {})[task.client];
1000
+ if (!cl || !Array.isArray(cl.command) || !cl.command.length) return { outcome: "error", detail: "no command for " + task.client + " on this computer (wake.json)" };
1001
+ let place;
1002
+ try { const plan = o.plan || planWorkdir(task, o.home || home(), o.git); place = prepareWorkdir(task, plan, dir, o.git); if (plan.kind === "scratch") place.scratch = true; }
1003
+ catch (e) { return { outcome: "error", detail: e.message }; }
1004
+ const prompt = wakePrompt(task, place, { machine: cfg.machine, timeout_min: cfg.timeout_min });
1005
+ const argv = [...cl.command, ...(cl.prompt === "arg" ? [prompt] : [])];
1006
+ mkdirSync(join(wakeDir(dir), "logs"), { recursive: true });
1007
+ const logPath = join(wakeDir(dir), "logs", "task-" + task.id + ".log");
1008
+ const fd = openSync(logPath, "a");
1009
+ const win = (o.platform || platform()) === "win32";
1010
+ const ms = o.timeoutMs ?? (cfg.timeout_min || WAKE_DEFAULTS.timeout_min) * 60000;
1011
+ try {
1012
+ return await new Promise((res) => {
1013
+ let ch, timedOut = false, killer = null;
1014
+ try {
1015
+ ch = spawn(argv[0], argv.slice(1), { cwd: place.cwd, stdio: ["pipe", fd, fd], detached: !win, shell: win, windowsHide: true,
1016
+ env: { ...process.env, OWLMEM_NO_BROWSER: "1", OWLMEM_WAKE_TASK: String(task.id) } });
1017
+ } catch (e) { res({ outcome: "error", detail: "couldn't start " + argv[0] + ": " + e.message }); return; }
1018
+ const kill = (sig) => { try { if (win) spawnSync("taskkill", ["/T", "/F", "/PID", String(ch.pid)]); else process.kill(-ch.pid, sig); } catch { try { ch.kill(sig); } catch {} } };
1019
+ const timer = setTimeout(() => { timedOut = true; kill("SIGTERM"); killer = setTimeout(() => kill("SIGKILL"), 10000); }, ms);
1020
+ ch.on("error", (e) => { clearTimeout(timer); clearTimeout(killer); res({ outcome: "error", detail: (e.code === "ENOENT" ? "command not found: " : "couldn't start: ") + argv[0] }); });
1021
+ ch.on("close", (code, sig) => { clearTimeout(timer); clearTimeout(killer); res(timedOut ? { outcome: "timeout", code } : { outcome: "exit", code: code ?? (sig ? 128 : null) }); });
1022
+ ch.stdin.on("error", () => {});
1023
+ ch.stdin.end(cl.prompt === "arg" ? "" : prompt);
1024
+ });
1025
+ } finally { closeSync(fd); }
1026
+ }
1027
+
1028
+ // One at a time per AI, one at a time per plain directory, at most `max` at once on the computer. start(job) → Promise<result>.
1029
+ export class Scheduler {
1030
+ constructor({ max = WAKE_DEFAULTS.max_parallel, start, onDone } = {}) {
1031
+ Object.assign(this, { max, start, onDone, queue: [], running: new Set(), agents: new Set(), locks: new Set() });
1032
+ }
1033
+ add(job) { this.queue.push(job); this.pump(); }
1034
+ pump() {
1035
+ for (let i = 0; i < this.queue.length && this.running.size < this.max;) {
1036
+ const j = this.queue[i];
1037
+ if (this.agents.has(j.agent) || (j.lock && this.locks.has(j.lock))) { i++; continue; }
1038
+ this.queue.splice(i, 1); this.running.add(j); this.agents.add(j.agent); if (j.lock) this.locks.add(j.lock);
1039
+ Promise.resolve().then(() => this.start(j)).catch((e) => ({ outcome: "error", detail: e?.message || String(e) })).then((res) => {
1040
+ this.running.delete(j); this.agents.delete(j.agent); if (j.lock) this.locks.delete(j.lock);
1041
+ try { this.onDone?.(j, res); } catch {}
1042
+ this.pump();
1043
+ });
1044
+ }
1045
+ }
1046
+ busy() { return [...new Set([...this.agents, ...this.queue.map((j) => j.agent)])]; }
1047
+ slots() { return Math.max(0, this.max - this.running.size - this.queue.length); }
1048
+ get size() { return this.running.size + this.queue.length; }
1049
+ }
1050
+
1051
+ // The long-poll loop. api(sub, body) → {status, data}. Returns "revoked" after cleaning up on a 401.
1052
+ export async function wakeLoop(o) {
1053
+ const { cfg, api, sched, log = () => {}, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), once = false, cleanup } = o;
1054
+ let backoff = 0;
1055
+ for (;;) {
1056
+ const t0 = Date.now();
1057
+ let r;
1058
+ try { r = await api("poll", { busy: sched.busy(), slots: sched.slots(), timeout: (cfg.timeout_min || WAKE_DEFAULTS.timeout_min) * 60, wait: 50, version: VERSION }); }
1059
+ catch (e) { r = { status: 0, data: { detail: e?.message || String(e) } }; }
1060
+ if (r.status === 401) {
1061
+ log("OwlMem no longer accepts this computer for auto-wake (" + (r.data?.detail || r.data?.error || "401") + "). Removing auto-wake from this computer.");
1062
+ if (cleanup) cleanup();
1063
+ return "revoked";
1064
+ }
1065
+ if (r.status !== 200) { // network trouble or a server hiccup: try again, waiting longer each time (up to 5 minutes)
1066
+ backoff = Math.min(300000, backoff ? backoff * 2 : 2000);
1067
+ log("Can't reach OwlMem (" + (r.data?.detail || "HTTP " + r.status) + "); trying again in " + Math.round(backoff / 1000) + " s.");
1068
+ if (once) return "error";
1069
+ await sleep(backoff); continue;
1070
+ }
1071
+ backoff = 0;
1072
+ for (const t of r.data.tasks || []) {
1073
+ const plan = o.plan ? o.plan(t) : planWorkdir(t);
1074
+ log("Task #" + t.id + " for " + t.agent + ": starting " + t.client + (plan.dir ? " in " + plan.dir : "") + ".");
1075
+ sched.add({ id: t.id, agent: t.agent, lock: plan.lock || null, task: t, plan });
1076
+ }
1077
+ if (once) return r.data.status;
1078
+ if (Date.now() - t0 < 1000 && !(r.data.tasks || []).length) await sleep(1000); // never spin
1079
+ }
1080
+ }
1081
+
1082
+ function wakeApi() {
1083
+ return async (sub, body) => {
1084
+ const url = BASE + "/v1/wake/" + sub;
1085
+ let r;
1086
+ try {
1087
+ const sh = await signHeaders("POST", url, "other");
1088
+ r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "owlmem-cli/" + VERSION, ...sh },
1089
+ body: JSON.stringify(body || {}), signal: AbortSignal.timeout(90000) });
1090
+ } catch (e) { return { status: 0, data: { detail: e.message } }; }
1091
+ return { status: r.status, data: await r.json().catch(() => ({})) };
1092
+ };
1093
+ }
1094
+
1095
+ function wakeLogger(dir = DIR) {
1096
+ const f = join(wakeDir(dir), "wake.log");
1097
+ return (msg) => {
1098
+ const line = new Date().toISOString() + " " + msg + "\n";
1099
+ try { mkdirSync(wakeDir(dir), { recursive: true }); if (existsSync(f) && statSync(f).size > 1 << 20) copyFileSync(f, f + ".1"), writeFileSync(f, ""); appendFileSync(f, line); } catch {}
1100
+ process.stderr.write(line);
1101
+ };
1102
+ }
1103
+
1104
+ async function wakeRun() {
1105
+ const cfg = loadWake();
1106
+ if (!cfg) throw new Fail("Auto-wake isn't set up on this computer. Run: npx -y owlmem wake enable");
1107
+ const log = wakeLogger(), api = wakeApi();
1108
+ const report = async (j, res) => {
1109
+ log("Task #" + j.id + ": " + res.outcome + (res.code != null ? " (exit code " + res.code + ")" : "") + (res.detail ? " — " + res.detail : ""));
1110
+ for (let i = 0; i < 5; i++) {
1111
+ const r = await api("report", { id: j.id, outcome: res.outcome, code: res.code ?? null, detail: res.detail || "" });
1112
+ if (r.status === 200 || r.status === 404 || r.status === 401) return;
1113
+ await new Promise((ok) => setTimeout(ok, 5000 * (i + 1)));
1114
+ }
1115
+ };
1116
+ const sched = new Scheduler({ max: cfg.max_parallel || WAKE_DEFAULTS.max_parallel, start: (j) => runTask(j.task, cfg, { plan: j.plan }), onDone: (j, res) => { report(j, res); } });
1117
+ log("OwlMem auto-wake " + VERSION + " running on " + (cfg.machine || hostname()) + ".");
1118
+ const out = await wakeLoop({ cfg, api, sched, log, cleanup: () => { try { wakeUninstall(autostartOpts(), wakeRunCmd, true); } catch (e) { log("cleanup: " + e.message); } } });
1119
+ if (out === "revoked") process.exit(0);
1120
+ }
1121
+
1122
+ async function wakeEnable(args = []) {
1123
+ const boot = args.includes("--boot"), again = args.includes("--again");
1124
+ const num = (name, lo, hi) => { const i = args.indexOf(name); if (i < 0) return undefined; const n = Number(args[i + 1]); if (!Number.isInteger(n) || n < lo || n > hi) throw new Fail(`${name} takes a whole number from ${lo} to ${hi}.`); return n; };
1125
+ const max = num("--max", 1, 16), timeout = num("--timeout", 1, 240);
1126
+ const old = loadWake() || {};
1127
+ const clients = { ...(old.clients || {}) };
1128
+ for (const [id, c] of Object.entries(WAKE_CLIENTS)) if (!clients[id] && onPath(c.bin)) clients[id] = { command: c.command, prompt: c.prompt };
1129
+ if (!Object.keys(clients).length)
1130
+ throw new Fail("No AI command line found on this computer that auto-wake can start (Claude Code: claude, Codex: codex). Install one, or add its command to " + wakeFile() + " under \"clients\".");
1131
+ const cfg = { v: 1, ...WAKE_DEFAULTS, ...old, machine: process.env.OWLMEM_DEVICE_NAME || hostname(), clients, ...(max ? { max_parallel: max } : {}), ...(timeout ? { timeout_min: timeout } : {}), boot: boot || !!old.boot };
1132
+ // OwlMem knows these app names; a command you add yourself to wake.json goes under "gemini" or "custom"
1133
+ const r = await wakeApi()("pair", { clients: Object.keys(clients).filter((c) => ["claude-code", "codex", "gemini", "custom"].includes(c)), force: again });
1134
+ if (r.status !== 200) throw new Fail("OwlMem didn't accept the pairing: " + (r.data.detail || r.data.error || "HTTP " + r.status));
1135
+ mkdirSync(wakeDir(), { recursive: true });
1136
+ copyFileSync(fileURLToPath(import.meta.url), join(wakeDir(), "owlmem.mjs")); // the service runs this copy (npx's cache can be cleaned out)
1137
+ if (r.data.status === "pending") cfg.pair_url = r.data.url; else delete cfg.pair_url;
1138
+ saveWake(DIR, cfg);
1139
+ const a = installAutostart(autostartOpts(DIR, { boot: cfg.boot }));
1140
+ say("Auto-wake is installed (" + a.kind + ": " + tilde(a.file) + ").");
1141
+ say("Apps it can start here: " + Object.keys(clients).map((c) => WAKE_CLIENTS[c]?.name || c).join(", ") + ". At most " + cfg.max_parallel + " at once, " + cfg.timeout_min + " minutes per task.");
1142
+ if (r.data.status === "pending") {
1143
+ say("\nApprove this computer in the OwlMem console and pick which AI each app is:\n\n " + r.data.url + "\n\nCheck that the console shows this code: " + r.data.code + "\nUntil then auto-wake waits and starts nothing.");
1144
+ openBrowser(r.data.url);
1145
+ } else say("This computer is already paired (" + r.data.code + "). Turn auto-wake on or off for each AI in the console (AI agents → Agents).");
1146
+ }
1147
+
1148
+ function wakeDisable(quiet = false) {
1149
+ const had = !!loadWake();
1150
+ const o = autostartOpts();
1151
+ const present = existsSync(autostart(o).file);
1152
+ if (!had && !present) { if (!quiet) say("Auto-wake isn't set up on this computer."); return false; }
1153
+ const api = wakeApi();
1154
+ return api("bye", {}).catch(() => null).then(() => {
1155
+ wakeUninstall(o);
1156
+ say("Auto-wake removed from this computer (autostart entry and settings). Worktrees and logs it made stay in " + tilde(wakeDir()) + ".");
1157
+ return true;
1158
+ });
1159
+ }
1160
+
1161
+ async function wakeStatus() {
1162
+ const cfg = loadWake(), a = autostart(autostartOpts());
1163
+ if (!cfg) { say("Auto-wake isn't set up on this computer. Turn it on: npx -y owlmem wake enable"); return; }
1164
+ say("Autostart: " + (existsSync(a.file) ? a.kind + " (" + tilde(a.file) + ")" : "missing"));
1165
+ say("Apps: " + Object.entries(cfg.clients || {}).map(([k, v]) => k + " → " + v.command.join(" ")).join("; "));
1166
+ say("At most " + cfg.max_parallel + " at once, " + cfg.timeout_min + " minutes per task. Log: " + tilde(join(wakeDir(), "wake.log")));
1167
+ const r = await wakeApi()("poll", { wait: 0, slots: 0 });
1168
+ if (r.status === 401) say("OwlMem: this computer isn't paired any more. Pair it again: npx -y owlmem wake enable --again");
1169
+ else if (r.status !== 200) say("OwlMem: can't reach it (" + (r.data.detail || "HTTP " + r.status) + ")");
1170
+ else if (r.data.status === "pending") say("OwlMem: waiting for approval in the console" + (cfg.pair_url ? ": " + cfg.pair_url : "") + " (code " + r.data.code + ")");
1171
+ else say("OwlMem: paired as " + r.data.code + ". " + r.data.agents.map((x) => x.agent + " (" + x.client + (x.wake ? "" : ", switched off") + ")").join(", "));
1172
+ }
1173
+
1174
+ const WAKE_HELP = `Usage: npx -y owlmem wake <enable|disable|status>
1175
+
1176
+ Auto-wake starts the AI on this computer when it gets an OwlMem task (Claude Code with \`claude -p\`, Codex with
1177
+ \`codex exec\`), without you opening it. It runs in the background from login, for this user only.
1178
+
1179
+ enable [--boot] [--max N] [--timeout MIN] [--again]
1180
+ install it and pair this computer (approve in the console). --boot (Linux): run from boot,
1181
+ not just login. --max: tasks at once (default 2). --timeout: minutes per task (default 30).
1182
+ --again: pair again (choose the AIs again).
1183
+ disable remove it from this computer (also done by npx -y owlmem uninstall)
1184
+ status show what's set up and what OwlMem says
1185
+
1186
+ Turn it off for one AI with the switch in the console (AI agents → Agents). Settings, including the command for
1187
+ each app ("claude-code", "codex", or your own under "gemini" / "custom"): ~/.config/owlmem/wake/wake.json`;
1188
+
1189
+ async function wakeCmd(sub, args) {
1190
+ if (sub === "enable") return wakeEnable(args);
1191
+ if (sub === "disable") return wakeDisable();
1192
+ if (sub === "status") return wakeStatus();
1193
+ if (sub === "run") return wakeRun();
1194
+ console.log(WAKE_HELP);
1195
+ if (sub && sub !== "help" && sub !== "--help") process.exitCode = 2;
1196
+ }
1197
+
768
1198
  const INSTALL_HELP = `Usage: npx -y owlmem install [options]
769
1199
 
770
1200
  Connects Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and Claude Desktop on this computer to
@@ -775,7 +1205,9 @@ as <file>.bak-owlmem-<time>. Also adds the OwlMem usage rules to CLAUDE.md / AGE
775
1205
  --yes, -y don't ask (non-interactive runs, e.g. an AI running this, never ask)
776
1206
  --only a,b only these apps: ${CLIENT_IDS.join(", ")}
777
1207
  --no-rules don't touch CLAUDE.md / AGENTS.md / GEMINI.md
778
- --uninstall remove OwlMem entries and the rules block (with backups)
1208
+ --uninstall remove OwlMem entries and the rules block (with backups), and auto-wake
1209
+ --wake also turn on auto-wake: start your AI when it gets a task (without --wake, --yes leaves it off)
1210
+ --no-wake don't ask about auto-wake
779
1211
 
780
1212
  Signing in happens in each app the first time it uses OwlMem (standard MCP OAuth): you click "Allow" once.`;
781
1213
 
@@ -785,6 +1217,7 @@ export async function main(argv) {
785
1217
  if (cmd === "install") { await install(argv.slice(1)); return; }
786
1218
  if (cmd === "uninstall") { await install(["--uninstall", ...argv.slice(1)]); return; }
787
1219
  if (cmd === "mcp") { await mcpServe(); return; }
1220
+ if (cmd === "wake") { await wakeCmd(sub, rest); return; }
788
1221
  if (cmd === "device") { // register this machine for standing grants and show its fingerprint
789
1222
  const r = await call("POST", "device", {});
790
1223
  if (r.status >= 400) throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "OwlMem command line: connect your AI apps to OwlMem in one command (npx -y owlmem install), an end-to-end encrypted vault for your AI agents, and a local MCP server.",
5
5
  "type": "module",
6
6
  "bin": { "owlmem": "owlmem.mjs" },