symbiot 0.39.1 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,7 +89,9 @@ it's already running just opens the existing window instead of starting a second
89
89
  copy (`SYMBIOT_FORCE_NEW=1` starts one anyway). If something else holds the port,
90
90
  it falls back to a free one.
91
91
  It works on **Linux, macOS and Windows**; with no Chromium-family browser to open
92
- the chrome-less window, it uses your default browser.
92
+ the chrome-less window, it uses your default browser. On an Android phone it runs
93
+ in Termux ([experimental](#on-your-phone-android-in-termux)), or as an
94
+ [Android app](#the-android-app-apk) of its own.
93
95
 
94
96
  ### Every week, and at login
95
97
 
@@ -110,6 +112,95 @@ repo is now archived), without Electron:
110
112
  then opens that copy's window. Switch it off to remove the entry. It needs an
111
113
  installed Symbiot (`npm install -g symbiot`), not `npx`.
112
114
 
115
+ ### On your phone (Android, in Termux)
116
+
117
+ Experimental, and not yet tried on a real phone. Symbiot is a Node CLI, so it runs
118
+ in [Termux](https://termux.dev) (install it from F-Droid; the Play Store build is
119
+ out of date) next to the coding agent you use there. It works on the repos on your
120
+ phone, the same way it works on your computer's:
121
+
122
+ ```bash
123
+ pkg install nodejs git gh termux-api # gh: Approve opens the PR; termux-api: notifications
124
+ npm install -g symbiot
125
+ symbiot app # opens in your phone's browser
126
+ ```
127
+
128
+ - The app opens in Symbiot's Android app if it's installed (below), else in the
129
+ phone's browser (through `termux-open-url`), and is laid out
130
+ for a phone screen: Tasks, Agents (with your agent's questions), Approve and
131
+ Settings all work there. Set your agent command in Settings as on a computer
132
+ (Claude Code is detected if it's on Termux's `PATH`). "Add to Home screen" in
133
+ Chrome gives it an icon.
134
+ - Clone repos into Termux's home (`~`), which Symbiot scans by default, along
135
+ with the home folders (`/root`, `/home/<you>`) of any Linux you run with
136
+ `proot-distro`. To reach
137
+ the phone's shared storage, run `termux-setup-storage` and add `~/storage/shared`
138
+ (or a folder in it) under Settings → Folders to scan.
139
+ - **Write my week and notify me** sends an Android notification when the
140
+ `termux-api` package and the Termux:API app are installed.
141
+ - **Start Symbiot in the background when the phone starts** writes a
142
+ [Termux:Boot](https://wiki.termux.com/wiki/Termux:Boot) script
143
+ (`~/.termux/boot/symbiot`) that takes a wake lock, so Android doesn't stop it.
144
+ Install Termux:Boot and open it once.
145
+ - **Screens** can't capture the screen from Termux; **Load image** still works.
146
+
147
+ ### The Android app (APK)
148
+
149
+ Experimental, and so far tried in the Android emulator (Android 14) and on one
150
+ phone (a Galaxy A26, Android 16). The app is Symbiot on its own, with no Termux needed: Node, git and `gh`
151
+ are inside it (the same builds Termux installs), and it shows Symbiot full screen
152
+ with its own icon.
153
+
154
+ - **Install:** on your phone, download `symbiot-aarch64.apk` from the
155
+ [latest release](https://github.com/GarthGhostai/symbiot/releases/latest)
156
+ (or copy `symbiot-<version>-aarch64.apk` over from a computer that built it)
157
+ and open it (allow your browser or file manager to install apps). Android may warn that the
158
+ app was built for an older version of Android. That's on purpose: it's what lets
159
+ an app run programs it carries, like Node and git, the same reason Termux does.
160
+ It also keeps the app off the Play Store.
161
+ - **First start** unpacks Node, git and gh (a few seconds), then asks for **All
162
+ files access**, so it can find the repos in your phone's shared storage. It scans
163
+ shared storage (for example the folders Termux's `~/storage/shared` points at)
164
+ instead of a home folder. Until it has that access, the Map says so and has an
165
+ **Allow file access** button. Once you allow it and come back, the Map scans again.
166
+ - **Its own Symbiot only sees shared storage.** Projects in Termux's home folder
167
+ (`~`) are private to Termux, and no other app can read them. Keep a project in
168
+ shared storage (in Termux, run `termux-setup-storage` once, then work under
169
+ `~/storage/shared`), or have the app show the Symbiot running in Termux (next
170
+ point). The repos on your computer aren't on the phone either, so clone the
171
+ ones you want.
172
+ - **Your Termux projects: show the Symbiot running in Termux.** A Symbiot started
173
+ in Termux sees everything in its home folder and runs your agent there, and the
174
+ app can be its window. **Open Termux** (on the Map when it finds no repos, and
175
+ in Settings → Projects in Termux) copies a command that installs Node and
176
+ Symbiot in Termux if they're missing, then runs `symbiot app`. Paste it in
177
+ Termux (it also updates Symbiot there if it's older than the app's). Termux
178
+ then opens Symbiot's link in the app, as a `symbiot://` link that only the app
179
+ takes, and the app shows that Symbiot instead of its own, and stops its own. It
180
+ keeps doing so until you tap **Use the app's own Symbiot**. If that Symbiot
181
+ stops, the app says so and offers both buttons. If the page opens in your
182
+ browser instead, it offers **Open in the app**. Termux from
183
+ Google Play can't take commands from other apps (it
184
+ has no `RUN_COMMAND`), so the app can't start Symbiot there by itself.
185
+ - It keeps running with its window closed (a quiet "Symbiot is running"
186
+ notification has **Stop**), so the weekly write-up, agent runs and Approve keep
187
+ going. **Start Symbiot in the background when the phone starts** and **Write my
188
+ week and notify me** work as they do on a computer, with Android notifications.
189
+ **Update & restart** updates it from npm, as on a computer.
190
+ - **Your coding agent isn't inside it.** Keep running Claude Code (or your agent)
191
+ in Termux, on the same repos in shared storage, the way you do now.
192
+ - **Screens:** no capture and no Map page (a phone has no desktop browser to
193
+ drive); **Load image** works.
194
+
195
+ To build it yourself: `android/build.sh` (or `android/build.sh x86_64` for the
196
+ emulator) writes `android/build/symbiot-<version>-<arch>.apk`. It needs Node, the
197
+ Android SDK and a JDK 17 (or Docker). The script's header lists the details,
198
+ including the signing key to keep (`~/.android/symbiot.jks`): an update has to be
199
+ signed with the same key. Each release also builds the aarch64 APK and attaches it
200
+ to a GitHub release tagged `apk-<version>`, signed with that same key once it's
201
+ in the repo's `SYMBIOT_KEYSTORE_B64` and `SYMBIOT_KEYSTORE_PASS` secrets (see
202
+ `.github/workflows/publish.yml`).
203
+
113
204
  ## Hand tasks to your coding agent: `symbiot push`
114
205
 
115
206
  ```bash
@@ -416,7 +507,53 @@ Hovering shows the pixel under the pointer. Each region keeps its position and s
416
507
  in the screenshot's own pixels, plus its **centre**, the point a click would aim at.
417
508
  **Copy blueprint** copies them as JSON; everything is also saved in
418
509
  `~/.config/symbiot/screens/screens.json` (next to each screen's PNG), where an
419
- agent or script can read it.
510
+ agent or script can read it. Screenshots and that file are readable by you only.
511
+
512
+ **A web page maps itself.** Type a site next to **Map page** (`gmail`,
513
+ `github.com/pulls` or a full address) and Symbiot opens it in a hidden (headless)
514
+ browser, takes its screenshot and marks every button, link and field on it by
515
+ itself, named from the page (`Compose`, `Search mail`, …) with what each one is
516
+ and a CSS selector to find it again. Nothing to bring to the front, nothing to
517
+ drag. Only what you could click right now counts: anything hidden, below the
518
+ fold or covered by something else is left out. **Press** on a region clicks it
519
+ in that hidden browser and maps the page it leads to as a new screen, so map,
520
+ press, map is how an agent finds its way around a site. **Type** on a field types
521
+ your text into it there (replacing what was in it) and, with **Type, then Enter**
522
+ ticked, presses Enter, which is how a search or a one-line form is sent; the
523
+ result is mapped as a new screen too. The hidden browser stays open for five
524
+ minutes after each map, press or type, so a press or type on the screen it just
525
+ mapped carries on from that page as it is: type into a field without Enter, then
526
+ press the form's own button (**Send**, **Next**) on the new screen. On an older
527
+ screen, or once the browser has closed, it opens the page from its address again,
528
+ and anything typed there is gone. Press and
529
+ Type act on the real site, signed in as you, so they ask first, unless the site
530
+ is under **Trusted sites** in Settings: there, they go ahead without asking, for
531
+ you and for agents. A trusted site covers its subdomains (`google.com` covers
532
+ `mail.google.com`), and only you add sites, in Settings (there's no command for
533
+ an agent to do it). The hidden browser is your
534
+ Chrome, Chromium, Edge or Brave, with a profile of its own
535
+ (`~/.config/symbiot/browser`, separate from your everyday one). For a site behind
536
+ a sign-in, **Sign in** opens it there as a normal window. Sign in once, close the
537
+ window, and later maps are signed in. When a map lands on a sign-in page, Symbiot
538
+ says so. It works for web pages only. A desktop app still needs **Capture screen**.
539
+
540
+ From a terminal, or for your coding agent, the same thing prints JSON:
541
+
542
+ ```bash
543
+ symbiot screens map gmail # the screen's id, and each region's id, label, kind, centre and selector
544
+ symbiot screens press <id> Compose --yes # press a region (by id or label), map where it lands
545
+ symbiot screens type <id> "Search mail" "invoice" --enter --yes # type into a field, press Enter, map the result
546
+ # (--yes isn't needed on a site under Trusted sites in Settings)
547
+ symbiot screens type <id> To "sam@example.com" --yes # without --enter: the text stays in the field...
548
+ symbiot screens press <new id> Send --yes # ...for a press on the screen that just printed
549
+ symbiot screens show <id> # a saved screen's blueprint
550
+ symbiot screens signin gmail # sign in once, in Symbiot's browser window
551
+ symbiot screens # list them
552
+ ```
553
+
554
+ While the app is running, these commands use its hidden browser, so it stays
555
+ open between them as it does in the app. Without the app, each command opens the
556
+ browser and closes it when done.
420
557
 
421
558
  **More than one display?** Symbiot reads how your displays are laid out
422
559
  (`cosmic-randr`, `wlr-randr`, `kscreen-doctor` or `xrandr` on Linux, PowerShell on
@@ -435,7 +572,7 @@ on your real screen. It asks you to confirm every time, and can wait 3, 5 or 10
435
572
  seconds first so you can bring the right window to the front. It clicks with
436
573
  `cliclick` on macOS (`brew install cliclick`), with PowerShell on Windows (nothing to
437
574
  install), and on Linux with `xdotool` on X11 or `ydotool` on Wayland (which needs
438
- access to `/dev/uinput`, and `ydotoold` running for ydotool 1.x). Nothing types yet.
575
+ access to `/dev/uinput`, and `ydotoold` running for ydotool 1.x). Nothing types on your real screen yet (Type works on a mapped web page only).
439
576
 
440
577
  Capture uses the screenshot tool your system has: `screencapture` on macOS,
441
578
  PowerShell on Windows, and on Linux `gnome-screenshot`, `spectacle`,
package/core.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { execSync } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
7
7
 
8
8
  // ---- config ---------------------------------------------------------------
9
9
  const CONFIG_DIR = join(homedir(), ".config", "symbiot");
@@ -31,6 +31,27 @@ function sh(cmd) {
31
31
  }
32
32
  // "Is this command available?": command -v on posix, where on win.
33
33
  function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
34
+ // A Chromium-family browser, per OS: the app's chrome-less --app window, and
35
+ // Screens' hidden (headless) browser.
36
+ function chromeBinary() {
37
+ const p = process.platform;
38
+ const exists = (f) => { try { return existsSync(f) ? f : null; } catch { return null; } };
39
+ if (p === "darwin") {
40
+ return ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
41
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
42
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
43
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"].map(exists).find(Boolean) || null;
44
+ }
45
+ if (p === "win32") {
46
+ const bases = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
47
+ const rels = ["Google\\Chrome\\Application\\chrome.exe", "Chromium\\Application\\chrome.exe",
48
+ "Microsoft\\Edge\\Application\\msedge.exe", "BraveSoftware\\Brave-Browser\\Application\\brave.exe"];
49
+ for (const base of bases) for (const r of rels) { const f = exists(join(base, r)); if (f) return f; }
50
+ const w = sh("where chrome 2>NUL").split(/\r?\n/).map((s) => s.trim()).find(Boolean);
51
+ return w || null;
52
+ }
53
+ return ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"].find(hasCmd) || null;
54
+ }
34
55
  // Working-tree state, incl. detecting a stale/old checkout (not new work).
35
56
  function repoState(repoPath) {
36
57
  const branch = sh(`git -C ${JSON.stringify(repoPath)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
@@ -50,4 +71,4 @@ function repoState(repoPath) {
50
71
  return { branch, dirty, del, mod, add, stale, staleBy, behind };
51
72
  }
52
73
 
53
- export { CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState };
74
+ export { CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, chromeBinary, repoState };
package/desktop.mjs CHANGED
@@ -7,9 +7,14 @@
7
7
  import { spawn } from "node:child_process";
8
8
  import { homedir } from "node:os";
9
9
  import { join, dirname } from "node:path";
10
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync } from "node:fs";
10
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync, chmodSync } from "node:fs";
11
11
  import { CONFIG_DIR, loadConfig, saveConfig, hasCmd } from "./core.mjs";
12
12
 
13
+ // The Android app (android/) runs this same code with SYMBIOT_ANDROID_APP=1. Its
14
+ // Java side posts the notifications and starts Symbiot at boot, so here it's an
15
+ // OS of its own: "android-app" (Termux on Android is plain "android").
16
+ const OS = process.env.SYMBIOT_ANDROID_APP === "1" ? "android-app" : process.platform;
17
+
13
18
  // ---- weekly write-up --------------------------------------------------------
14
19
  // config.weekly = { on, day (0 = Sunday … 6 = Saturday), hour (0-23), last (ms) }.
15
20
  // Each run saves the write-up to ~/.config/symbiot/weeks/<date>.md.
@@ -73,30 +78,39 @@ function startWeekly(produce) {
73
78
 
74
79
  // ---- desktop notification (no dependencies) ---------------------------------
75
80
  // [cmd, args] for this OS, or null when there's nothing to notify with.
76
- function notifyCmd(title, body, platform = process.platform) {
81
+ function notifyCmd(title, body, platform = OS) {
77
82
  if (platform === "darwin") return ["osascript", ["-e", `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`]];
78
83
  if (platform === "win32") {
79
84
  const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
80
85
  return ["powershell", ["-NoProfile", "-WindowStyle", "Hidden", "-Command", `Add-Type -AssemblyName System.Windows.Forms; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(10000, ${q(title)}, ${q(body)}, 'Info'); Start-Sleep -Seconds 11; $n.Dispose()`]];
81
86
  }
87
+ // Android (Termux): termux-notification, from the termux-api package + the Termux:API app
88
+ if (platform === "android") return hasCmd("termux-notification") ? ["termux-notification", ["--id", "symbiot", "--title", title, "--content", body]] : null;
82
89
  return hasCmd("notify-send") ? ["notify-send", ["--app-name=Symbiot", title, body]] : null;
83
90
  }
84
- function desktopNotify(title, body) {
85
- const c = notifyCmd(title, body); if (!c) return false;
91
+ // In the app: a line in a file its service watches, and it posts the notification.
92
+ function desktopNotify(title, body, platform = OS, dir = CONFIG_DIR) {
93
+ if (platform === "android-app") { try { mkdirSync(dir, { recursive: true }); appendFileSync(join(dir, "android-notify.jsonl"), JSON.stringify({ title, body }) + "\n"); return true; } catch { return false; } }
94
+ const c = notifyCmd(title, body, platform); if (!c) return false;
86
95
  try { const ch = spawn(c[0], c[1], { detached: true, stdio: "ignore" }); ch.on("error", () => {}); ch.unref(); return true; } catch { return false; }
87
96
  }
88
97
 
89
98
  // ---- start at login -----------------------------------------------------------
90
99
  // One file per OS, in the place that OS starts things from at login: an XDG
91
- // autostart entry (Linux), a LaunchAgent (macOS), the Startup folder (Windows).
100
+ // autostart entry (Linux), a LaunchAgent (macOS), the Startup folder (Windows),
101
+ // a Termux:Boot script (Android, run when the phone starts). The Android app
102
+ // starts itself at boot while its flag file is there.
92
103
  // It runs `symbiot app` with SYMBIOT_NO_OPEN=1, so no window pops up. PATH is
93
104
  // saved too, so agents and git resolve as they do in your terminal.
94
- function autostartFile(platform = process.platform, home = homedir()) {
105
+ function autostartFile(platform = OS, home = homedir()) {
106
+ if (platform === "android-app") return join(home, ".config", "symbiot", "android-boot");
95
107
  if (platform === "darwin") return join(home, "Library", "LaunchAgents", "co.symbiot.app.plist");
96
108
  if (platform === "win32") return join(home, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "symbiot.cmd");
109
+ if (platform === "android") return join(home, ".termux", "boot", "symbiot");
97
110
  return join(home, ".config", "autostart", "symbiot.desktop");
98
111
  }
99
- function autostartContent(node, script, platform = process.platform, path = process.env.PATH || "") {
112
+ function autostartContent(node, script, platform = OS, path = process.env.PATH || "") {
113
+ if (platform === "android-app") return "While this file exists, Symbiot's Android app starts itself when the phone starts.\n";
100
114
  if (platform === "darwin") {
101
115
  const x = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
102
116
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -109,13 +123,19 @@ function autostartContent(node, script, platform = process.platform, path = proc
109
123
  </dict></plist>
110
124
  `;
111
125
  }
126
+ if (platform === "android") {
127
+ // sh single quotes; the wake lock stops Android from stopping Termux while Symbiot runs
128
+ const sq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
129
+ return ["#!/data/data/com.termux/files/usr/bin/sh", "command -v termux-wake-lock >/dev/null && termux-wake-lock",
130
+ "export SYMBIOT_NO_OPEN=1 PATH=" + sq(path), "exec " + sq(node) + " " + sq(script) + " app", ""].join("\n");
131
+ }
112
132
  if (platform === "win32") return ["@echo off", "set SYMBIOT_NO_OPEN=1", `start "Symbiot" /min "${node}" "${script}" app`, ""].join("\r\n");
113
133
  // Desktop Entry Exec quoting: double quotes, with " ` $ \ backslash-escaped
114
134
  const q = (s) => '"' + String(s).replace(/(["`$\\])/g, "\\$1") + '"';
115
135
  return ["[Desktop Entry]", "Type=Application", "Name=Symbiot", "Comment=Symbiot in the background: the weekly write-up and its notification",
116
136
  `Exec=env SYMBIOT_NO_OPEN=1 ${q("PATH=" + path)} ${q(node)} ${q(script)} app`, "Terminal=false", "NoDisplay=true", "X-GNOME-Autostart-enabled=true", ""].join("\n");
117
137
  }
118
- function autostartState() { const file = autostartFile(); return { on: existsSync(file), file }; }
138
+ function autostartState() { const file = autostartFile(); return { on: existsSync(file), file, ...(OS === "android-app" ? { phone: "app" } : OS === "android" ? { phone: "termux" } : {}) }; }
119
139
  // `script` is index.mjs's real path. From npx that's a cache folder that goes
120
140
  // away, so it has to be installed for this to keep working.
121
141
  function setAutostart(on, script) {
@@ -125,6 +145,7 @@ function setAutostart(on, script) {
125
145
  if (/[\\/]_npx[\\/]/.test(String(script))) return { ...autostartState(), error: "Symbiot is running from npx. Install it first (npm install -g symbiot), then switch this on." };
126
146
  mkdirSync(dirname(file), { recursive: true });
127
147
  writeFileSync(file, autostartContent(process.execPath, script));
148
+ if (OS === "android") chmodSync(file, 0o700); // Termux:Boot only runs executable scripts
128
149
  return autostartState();
129
150
  } catch (e) { return { ...autostartState(), error: String((e && e.message) || e) }; }
130
151
  }