symbiot 0.36.1 → 0.37.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.
package/README.md CHANGED
@@ -52,7 +52,8 @@ Tabs:
52
52
  one-line verdict on whether it needs new work at all (it prefers stabilising over
53
53
  new features), and ideas you can tick straight into **Tasks**. The graph needs
54
54
  **no AI key**; reviews and suggestions use your chosen model. While the scan runs
55
- the Map shows its progress.
55
+ the Map shows its progress. Under the graph is the [experimental](#screens-blueprints-for-screen-automation)
56
+ **Screens** view.
56
57
  - **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
57
58
  a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
58
59
  - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
@@ -71,7 +72,8 @@ Tabs:
71
72
  did** — files changed and commits made, read from git, whichever agent it was.
72
73
  When an agent leaves **questions, options or ideas** for you, they show up on its
73
74
  block (see [Questions from your agent](#questions-from-your-agent)).
74
- - **Settings** — your AI, the folders to scan and your agent command, plus the
75
+ - **Settings** — your AI, the folders to scan, your agent command, the
76
+ [weekly write-up and start at login](#every-week-and-at-login), plus the
75
77
  [experimental](#experimental) email, model recommendations and one-click local
76
78
  model.
77
79
 
@@ -89,6 +91,25 @@ it falls back to a free one.
89
91
  It works on **Linux, macOS and Windows**; with no Chromium-family browser to open
90
92
  the chrome-less window, it uses your default browser.
91
93
 
94
+ ### Every week, and at login
95
+
96
+ Two switches in Settings do what the old `symbiot-desktop` tray app did (that
97
+ repo is now archived), without Electron:
98
+
99
+ - **Write my week and notify me** on a day and hour you pick (Friday 16:00 by
100
+ default). The app writes your week with your chosen AI, saves it to
101
+ `~/.config/symbiot/weeks/<date>.md`, and sends a desktop notification
102
+ (`notify-send` on Linux, Notification Center on macOS, a tray balloon on
103
+ Windows). The Week tab shows it until you write a new one. It runs while
104
+ `symbiot app` is running. A week missed while the computer was off is written
105
+ when the app next starts, once. **Write it now** checks that it works.
106
+ - **Start Symbiot in the background when I log in** adds an autostart entry
107
+ (`~/.config/autostart/symbiot.desktop` on Linux, a LaunchAgent on macOS, the
108
+ Startup folder on Windows) that starts `symbiot app` with no window, so the
109
+ weekly write-up happens even on days you don't open it. Running `symbiot app`
110
+ then opens that copy's window. Switch it off to remove the entry. It needs an
111
+ installed Symbiot (`npm install -g symbiot`), not `npx`.
112
+
92
113
  ## Hand tasks to your coding agent: `symbiot push`
93
114
 
94
115
  ```bash
@@ -173,9 +194,14 @@ One agent per repo: while a run is still going (tracked in
173
194
  `.symbiot/agent.pid`, so the app and `symbiot push --open` both see it), sending
174
195
  again doesn't start a second agent there, and doesn't rewrite the `TASKS.md` it's
175
196
  working from either: the new tasks wait in `.symbiot/TASKS.next.md` and replace
176
- `TASKS.md` when it finishes, keeping anything it ticked. Send again then to start
177
- an agent on them. Orca and editor presets only open a tab and exit, so Symbiot
178
- can't see the agent you run in them. Send to repos stays disabled until its
197
+ `TASKS.md` when it finishes, keeping anything it ticked. The agent's block in the
198
+ **Agents** tab shows "tasks held" meanwhile (hover it for their titles), and
199
+ when the agent finishes, the app starts one on the held tasks by itself. If
200
+ `symbiot push --open` started the running agent, the app can't see it exit, so
201
+ it starts one the next time it checks that repo after it finishes (opening the
202
+ **Tasks** tab), or you can send again. Orca and
203
+ editor presets only open a tab and exit, so Symbiot can't see the agent you run
204
+ in them. Send to repos stays disabled until its
179
205
  handoffs have started, so a double click can't send twice.
180
206
 
181
207
  ### Questions from your agent
@@ -373,6 +399,35 @@ the one-line install for your OS and you re-run it after. Both are buttons in
373
399
  Settings, where the download shows in the Agents tab. You can always pick a model
374
400
  yourself with `symbiot login`.
375
401
 
402
+ ### Screens: blueprints for screen automation
403
+
404
+ Under the Map, **Screens** builds the map that screen automation needs: what's on a
405
+ screen, and where. **Capture screen** (now, or after a few seconds so you can bring
406
+ the right window to the front) or **Load image** for a PNG you already have, then
407
+ drag a box over each part that matters (a button, a field, a menu) and name it.
408
+ Hovering shows the pixel under the pointer. Each region keeps its position and size
409
+ in the screenshot's own pixels, plus its **centre**, the point a click would aim at.
410
+ **Copy blueprint** copies them as JSON; everything is also saved in
411
+ `~/.config/symbiot/screens/screens.json` (next to each screen's PNG), where an
412
+ agent or script can read it.
413
+
414
+ **Click here** on a region moves your mouse to the region's centre and clicks there,
415
+ on your real screen. It asks you to confirm every time, and can wait 3, 5 or 10
416
+ seconds first so you can bring the right window to the front. It clicks with
417
+ `cliclick` on macOS (`brew install cliclick`), and on Linux with `xdotool` on X11 or
418
+ `ydotool` on Wayland (which needs access to `/dev/uinput`, and `ydotoold` running for
419
+ ydotool 1.x). Windows can't click yet. Nothing types yet.
420
+
421
+ Capture uses the screenshot tool your system has: `screencapture` on macOS,
422
+ PowerShell on Windows, and on Linux `gnome-screenshot`, `spectacle`,
423
+ `cosmic-screenshot`, `grim` or `xfce4-screenshooter` (plus `scrot` / ImageMagick
424
+ `import` on X11). Why it's experimental: Wayland desktops restrict screenshots, so
425
+ capture may fail or ask for permission (use **Load image** then). And on a scaled
426
+ (HiDPI) display, screenshot pixels can differ from the coordinates a click tool
427
+ expects. Symbiot rescales for `xdotool` and for `cliclick` on a Retina screen, but
428
+ it can't read the scale on Wayland, and ydotool's moves follow your pointer
429
+ acceleration, so a click there can land off target.
430
+
376
431
  ## Config, development and releasing
377
432
 
378
433
  Config files, environment variables (`SYMBIOT_MODEL`, `SYMBIOT_PORT`, …), the test
package/agents.mjs CHANGED
@@ -68,8 +68,7 @@ function runHandoff(repoPath) {
68
68
  const lock = join(repoPath, ".symbiot", LOCK);
69
69
  const e = track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, () => {
70
70
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
71
- // tasks sent while it ran land now; start on them as that Send would have
72
- if (releaseHeldTasks(repoPath) && /^\s*-\s*\[ \]/m.test(readSymbiot(repoPath, "TASKS.md"))) { const n = runHandoff(repoPath); if (n && !n.busy) n.fromHeld = true; }
71
+ startHeldTasks(repoPath); // tasks sent while it ran land now; start on them as that Send would have
73
72
  });
74
73
  if (!e) return null;
75
74
  e.handoff = true;
@@ -111,6 +110,16 @@ function releaseHeldTasks(path) {
111
110
  const md = held.split("\n").map((l) => /^\s*-\s*\[ \]/.test(l) && ticked.has(key(l)) ? l.replace("[ ]", "[x]") : l).join("\n");
112
111
  try { writeFileSync(join(path, ".symbiot", "TASKS.md"), md); unlinkSync(join(path, ".symbiot", HELD)); return true; } catch { return false; }
113
112
  }
113
+ // Land the held brief and, if it leaves anything open, start an agent on it.
114
+ // Runs when an agent this process started exits, and when the app next checks
115
+ // the repo (syncTasks), which covers one `symbiot push --open` started: that
116
+ // process is gone by the time its agent finishes. A held brief is released only
117
+ // once, so this can't loop. Returns the new job, or null.
118
+ function startHeldTasks(path) {
119
+ if (!releaseHeldTasks(path) || !/^\s*-\s*\[ \]/m.test(readSymbiot(path, "TASKS.md"))) return null;
120
+ const n = runHandoff(path); if (!n || n.busy) return null;
121
+ n.fromHeld = true; return n;
122
+ }
114
123
  // Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
115
124
  // where the CLI isn't on PATH (they're .app bundles).
116
125
  const IDE_LIST = [["code", "VS Code", "Visual Studio Code"], ["cursor", "Cursor", "Cursor"], ["windsurf", "Windsurf", "Windsurf"], ["zed", "Zed", "Zed"], ["subl", "Sublime Text", "Sublime Text"], ["idea", "IntelliJ IDEA", "IntelliJ IDEA"], ["nvim", "Neovim", ""]];
@@ -318,10 +327,10 @@ function answerQuestions(path, answers, opts = {}) {
318
327
  if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
319
328
  return out;
320
329
  }
321
- // Open tasks in a held brief (TASKS.next.md), or null if nothing is held.
330
+ // The open tasks' titles in a held brief (TASKS.next.md), or null if nothing is held.
322
331
  function heldTasks(path) {
323
332
  const md = readSymbiot(path, HELD);
324
- return md ? md.split("\n").filter((l) => /^\s*-\s*\[ \]/.test(l)).length : null;
333
+ return md ? md.split("\n").filter((l) => /^\s*-\s*\[ \]/.test(l)).map((l) => l.replace(/^\s*-\s*\[ \]\s*/, "").trim().slice(0, 200)) : null;
325
334
  }
326
335
  // The Agents tab: every tracked job, its log tail, what it changed, and — on the
327
336
  // newest job per folder — the questions/ideas it left and any tasks held for it.
@@ -334,4 +343,4 @@ function agentsList() {
334
343
  });
335
344
  }
336
345
 
337
- export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };
346
+ export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };
package/desktop.mjs ADDED
@@ -0,0 +1,132 @@
1
+ // symbiot — what the symbiot-desktop tray app added, now part of `symbiot app`:
2
+ // a weekly write-up with a desktop notification, and starting at login.
3
+ // No Electron: the app's own server runs the weekly schedule while it's up, and
4
+ // start-at-login launches that server in the background (no window) when you
5
+ // log in, so the write-up still happens on days you never open Symbiot.
6
+ // `symbiot app` then finds that running copy and opens its window.
7
+ import { spawn } from "node:child_process";
8
+ import { homedir } from "node:os";
9
+ import { join, dirname } from "node:path";
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync } from "node:fs";
11
+ import { CONFIG_DIR, loadConfig, saveConfig, hasCmd } from "./core.mjs";
12
+
13
+ // ---- weekly write-up --------------------------------------------------------
14
+ // config.weekly = { on, day (0 = Sunday … 6 = Saturday), hour (0-23), last (ms) }.
15
+ // Each run saves the write-up to ~/.config/symbiot/weeks/<date>.md.
16
+ const WEEKS_DIR = join(CONFIG_DIR, "weeks");
17
+ const intIn = (v, lo, hi, def) => { const n = v === "" || v == null ? NaN : Number(v); return Number.isInteger(n) && n >= lo && n <= hi ? n : def; };
18
+ function weeklyCfg(cfg = loadConfig()) {
19
+ const w = cfg.weekly || {};
20
+ return { on: !!w.on, day: intIn(w.day, 0, 6, 5), hour: intIn(w.hour, 0, 23, 16), last: Number(w.last) || 0 };
21
+ }
22
+ // The latest scheduled moment at or before `now`, in local time.
23
+ function lastSlot(now, day, hour) {
24
+ const d = new Date(now); d.setHours(hour, 0, 0, 0);
25
+ d.setDate(d.getDate() - ((d.getDay() - day + 7) % 7));
26
+ if (d.getTime() > now) d.setDate(d.getDate() - 7);
27
+ return d.getTime();
28
+ }
29
+ // Due when it's on and hasn't run since the latest slot, so a slot missed while
30
+ // the computer was off runs as soon as the app is up again (once, not per week missed).
31
+ function weeklyDue(w, now = Date.now()) { return !!w.on && w.last < lastSlot(now, w.day, w.hour); }
32
+ // Any change to the schedule counts as a run now, so switching it on (or moving
33
+ // the day) never fires straight away for a slot that's already past.
34
+ function setWeekly(b = {}) {
35
+ const cfg = loadConfig(), w = weeklyCfg(cfg);
36
+ if (typeof b.on === "boolean") w.on = b.on;
37
+ w.day = intIn(b.day, 0, 6, w.day); w.hour = intIn(b.hour, 0, 23, w.hour); w.last = Date.now();
38
+ cfg.weekly = w; saveConfig(cfg);
39
+ return weeklyState();
40
+ }
41
+ const localDay = (t = Date.now()) => { const d = new Date(t); return [d.getFullYear(), d.getMonth() + 1, d.getDate()].map((n) => String(n).padStart(2, "0")).join("-"); };
42
+ const FOOT = "\n\n---\n";
43
+ // The newest saved write-up, or null.
44
+ function latestWeek() {
45
+ try {
46
+ const f = readdirSync(WEEKS_DIR).filter((x) => /^\d{4}-\d{2}-\d{2}\.md$/.test(x)).sort().pop(); if (!f) return null;
47
+ const file = join(WEEKS_DIR, f), raw = readFileSync(file, "utf8"), i = raw.lastIndexOf(FOOT);
48
+ return { file, at: statSync(file).mtimeMs, text: (i >= 0 ? raw.slice(0, i) : raw).trim(), footer: i >= 0 ? raw.slice(i + FOOT.length).trim() : "" };
49
+ } catch { return null; }
50
+ }
51
+ function weeklyState() { return { ...weeklyCfg(), latest: latestWeek() }; }
52
+ // Write the week with `produce` (index.mjs), save it and notify. `last` is set
53
+ // first, so a slow or failing run is never retried every minute.
54
+ async function runWeekly(produce) {
55
+ const cfg = loadConfig(); cfg.weekly = { ...weeklyCfg(cfg), last: Date.now() }; saveConfig(cfg);
56
+ const r = await produce("week");
57
+ if (r.error === "not-connected") { desktopNotify("Symbiot", "Time for your weekly update. Connect an AI in Symbiot's Settings and it writes it for you."); return { error: r.error }; }
58
+ if (/^\(?couldn't reach the model/i.test(String(r.text || ""))) { desktopNotify("Symbiot", "Couldn't write your week: the model didn't answer. Try the Week tab."); return { error: r.text }; }
59
+ const file = join(WEEKS_DIR, localDay() + ".md");
60
+ try { mkdirSync(WEEKS_DIR, { recursive: true }); writeFileSync(file, String(r.text || "").trim() + (r.footer ? FOOT + r.footer : "") + "\n"); }
61
+ catch (e) { return { error: "Couldn't save the write-up: " + ((e && e.message) || e) }; }
62
+ desktopNotify("Symbiot", "Your week is written. Open the Week tab in Symbiot, or " + file);
63
+ return { ok: true, file, text: r.text };
64
+ }
65
+ // Checks once a minute while the app runs. Returns a stop function.
66
+ function startWeekly(produce) {
67
+ let busy = false;
68
+ const tick = () => { if (busy || !weeklyDue(weeklyCfg())) return; busy = true; runWeekly(produce).catch(() => {}).finally(() => { busy = false; }); };
69
+ const first = setTimeout(tick, 5000), every = setInterval(tick, 60 * 1000);
70
+ first.unref(); every.unref();
71
+ return () => { clearTimeout(first); clearInterval(every); };
72
+ }
73
+
74
+ // ---- desktop notification (no dependencies) ---------------------------------
75
+ // [cmd, args] for this OS, or null when there's nothing to notify with.
76
+ function notifyCmd(title, body, platform = process.platform) {
77
+ if (platform === "darwin") return ["osascript", ["-e", `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`]];
78
+ if (platform === "win32") {
79
+ const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
80
+ 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
+ }
82
+ return hasCmd("notify-send") ? ["notify-send", ["--app-name=Symbiot", title, body]] : null;
83
+ }
84
+ function desktopNotify(title, body) {
85
+ const c = notifyCmd(title, body); if (!c) return false;
86
+ try { const ch = spawn(c[0], c[1], { detached: true, stdio: "ignore" }); ch.on("error", () => {}); ch.unref(); return true; } catch { return false; }
87
+ }
88
+
89
+ // ---- start at login -----------------------------------------------------------
90
+ // 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).
92
+ // It runs `symbiot app` with SYMBIOT_NO_OPEN=1, so no window pops up. PATH is
93
+ // saved too, so agents and git resolve as they do in your terminal.
94
+ function autostartFile(platform = process.platform, home = homedir()) {
95
+ if (platform === "darwin") return join(home, "Library", "LaunchAgents", "co.symbiot.app.plist");
96
+ if (platform === "win32") return join(home, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "symbiot.cmd");
97
+ return join(home, ".config", "autostart", "symbiot.desktop");
98
+ }
99
+ function autostartContent(node, script, platform = process.platform, path = process.env.PATH || "") {
100
+ if (platform === "darwin") {
101
+ const x = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
102
+ return `<?xml version="1.0" encoding="UTF-8"?>
103
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
104
+ <plist version="1.0"><dict>
105
+ <key>Label</key><string>co.symbiot.app</string>
106
+ <key>ProgramArguments</key><array><string>${x(node)}</string><string>${x(script)}</string><string>app</string></array>
107
+ <key>EnvironmentVariables</key><dict><key>SYMBIOT_NO_OPEN</key><string>1</string><key>PATH</key><string>${x(path)}</string></dict>
108
+ <key>RunAtLoad</key><true/>
109
+ </dict></plist>
110
+ `;
111
+ }
112
+ if (platform === "win32") return ["@echo off", "set SYMBIOT_NO_OPEN=1", `start "Symbiot" /min "${node}" "${script}" app`, ""].join("\r\n");
113
+ // Desktop Entry Exec quoting: double quotes, with " ` $ \ backslash-escaped
114
+ const q = (s) => '"' + String(s).replace(/(["`$\\])/g, "\\$1") + '"';
115
+ return ["[Desktop Entry]", "Type=Application", "Name=Symbiot", "Comment=Symbiot in the background: the weekly write-up and its notification",
116
+ `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
+ }
118
+ function autostartState() { const file = autostartFile(); return { on: existsSync(file), file }; }
119
+ // `script` is index.mjs's real path. From npx that's a cache folder that goes
120
+ // away, so it has to be installed for this to keep working.
121
+ function setAutostart(on, script) {
122
+ const file = autostartFile();
123
+ try {
124
+ if (!on) { if (existsSync(file)) unlinkSync(file); return autostartState(); }
125
+ 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
+ mkdirSync(dirname(file), { recursive: true });
127
+ writeFileSync(file, autostartContent(process.execPath, script));
128
+ return autostartState();
129
+ } catch (e) { return { ...autostartState(), error: String((e && e.message) || e) }; }
130
+ }
131
+
132
+ export { weeklyCfg, lastSlot, weeklyDue, setWeekly, weeklyState, latestWeek, runWeekly, startWeekly, notifyCmd, desktopNotify, autostartFile, autostartContent, autostartState, setAutostart };
package/index.mjs CHANGED
@@ -30,8 +30,10 @@ import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { EMBEDDED_UI } from "./ui.mjs";
31
31
  import { detectMailSources, mailActivity } from "./mail.mjs";
32
32
  import { CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState } from "./core.mjs";
33
- import { HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, parseQuestions, agentQuestions, answerQuestions, agentsList } from "./agents.mjs";
33
+ import { HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, runningHandoff, writeTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, parseQuestions, agentQuestions, answerQuestions, agentsList } from "./agents.mjs";
34
34
  import { gitDefaultBranch, loadDeploys, driftRepo } from "./drift.mjs";
35
+ import { loadScreens, screenImage, captureScreen, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
36
+ import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
35
37
 
36
38
  const HERE = fileURLToPath(new URL(".", import.meta.url));
37
39
  let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch {}
@@ -125,16 +127,18 @@ function repoPathMap() {
125
127
  // please review", NOT archived: it waits in review until the user approves it
126
128
  // (approveRepo: branch + commit + push + PR, then archive) or sends it back
127
129
  // (sendBack: unticked, open again). Only a task the USER ticks archives directly.
130
+ // Tasks held for an agent that has since finished land here too, and an agent
131
+ // starts on what's still open in them (one `push --open` started can't do that).
128
132
  function syncTasks() {
129
- const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0; const checkedByRepo = {};
133
+ const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0, started = 0; const checkedByRepo = {};
130
134
  for (const x of t) {
131
135
  if (x.archived || x.done || x.review || !x.repo) continue;
132
- if (!(x.repo in checkedByRepo)) { const p = map[x.repo]; if (p) releaseHeldTasks(p); checkedByRepo[x.repo] = p ? completedInRepo(p) : []; }
136
+ if (!(x.repo in checkedByRepo)) { const p = map[x.repo]; if (p && startHeldTasks(p)) started++; checkedByRepo[x.repo] = p ? completedInRepo(p) : []; }
133
137
  if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.review = true; x.reviewAt = Date.now(); review++; }
134
138
  }
135
139
  for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
136
140
  saveTasks(t);
137
- return { review, archived };
141
+ return { review, archived, started };
138
142
  }
139
143
  // git with an argv (task text goes into commit messages — never through a shell)
140
144
  function git(repo, args, timeout = 30000) {
@@ -161,18 +165,21 @@ function workingDiff(path, cap = 400000) {
161
165
  }
162
166
  // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
163
167
  // uncommitted changes no ticked task covers (untasked: approve them as-is).
168
+ // running: an agent is still editing there, so its changes may be half done.
164
169
  function pendingReview() {
165
170
  const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
166
171
  const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
167
172
  const map = Object.keys(by).length || sent.length ? repoPathMap() : {};
168
173
  const am = autoMergeRepos();
169
- const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), ...(path ? workingChanges(path) : { branch: "", files: [], stat: "" }) }; });
174
+ const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), running: !!(path && runningHandoff(path)), ...(path ? workingChanges(path) : { branch: "", files: [], stat: "" }) }; });
170
175
  for (const repo of sent.sort()) {
171
176
  const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
172
- const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), ...wc });
177
+ const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc });
173
178
  }
174
179
  return out;
175
180
  }
181
+ // Approving while the agent is still editing would commit its half-done work.
182
+ const stillWorking = (repo, path) => path && runningHandoff(path) ? { error: `The agent is still working in ${repo}. Approve once it finishes.`, running: true } : null;
176
183
  const branchSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "tasks";
177
184
  // Sync approved work: off the default branch onto symbiot/<task>, commit the
178
185
  // working tree (minus .symbiot/), push, and open a PR with gh. Each step that
@@ -228,6 +235,7 @@ function approveRepo(repo, opts = {}) {
228
235
  const t = loadTasks(); const items = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
229
236
  if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
230
237
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
238
+ const busy = stillWorking(repo, path); if (busy) return busy;
231
239
  const r = shipChanges(path, items.map((x) => x.text), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
232
240
  if (r.error) return r;
233
241
  const now = Date.now();
@@ -241,6 +249,7 @@ function approveRepo(repo, opts = {}) {
241
249
  function approveChanges(repo, opts = {}) {
242
250
  if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
243
251
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
252
+ const busy = stillWorking(repo, path); if (busy) return busy;
244
253
  if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
245
254
  const r = shipChanges(path, [], { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
246
255
  return r.error ? r : { ...r, approved: 0 };
@@ -328,10 +337,10 @@ function cmdPush() {
328
337
  if (has("open")) {
329
338
  if (!r.handoff) console.log("\n" + c.y("No agent command set.") + c.d(" Set one in `symbiot app` Settings, or `agentCmd` in ~/.config/symbiot/config.json (use {dir} and {prompt})."));
330
339
  else {
331
- const busy = r.written.filter((w) => { const e = runHandoff(w.path); return e && e.busy; });
340
+ const busy = r.written.map((w) => ({ w, e: runHandoff(w.path) })).filter((x) => x.e && x.e.busy);
332
341
  const n = r.written.length - busy.length;
333
342
  if (n) console.log("\n" + c.g("→ ") + `Handed ${n} repo(s) to your agent (${r.handoff}).`);
334
- for (const w of busy) console.log("\n" + c.y("Not started: ") + `${w.name} already has an agent running.` + c.d(" Its new tasks are held until it finishes; send again then to start one on them."));
343
+ for (const { w, e } of busy) console.log("\n" + c.y("Not started: ") + `${w.name} already has an agent running.` + c.d(e.auto ? " Its new tasks are held, and the app starts an agent on them when it finishes." : " Its new tasks are held until it finishes. After that, the app starts an agent on them the next time it checks the repo (opening its Tasks tab), or send again."));
335
344
  }
336
345
  } else {
337
346
  console.log("\n" + c.d("Point your agent at .symbiot/TASKS.md in each repo. (add --open to run your configured agent command)"));
@@ -1448,6 +1457,7 @@ async function cmdApp() {
1448
1457
  } catch {}
1449
1458
  }
1450
1459
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
1460
+ const screenOut = (s) => (s && s.id ? { ...s, blueprint: blueprint(s) } : s);
1451
1461
  const server = createServer(async (req, res) => {
1452
1462
  const u = new URL(req.url, "http://127.0.0.1");
1453
1463
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
@@ -1485,7 +1495,7 @@ async function cmdApp() {
1485
1495
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1486
1496
  if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
1487
1497
  if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
1488
- if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || "")); return json(res, { opened: !!e && !e.busy, busy: !!(e && e.busy), id: e ? e.id : "" }); }
1498
+ if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || "")); return json(res, { opened: !!e && !e.busy, busy: !!(e && e.busy), auto: !!(e && e.auto), id: e ? e.id : "" }); }
1489
1499
  if (u.pathname === "/api/setup-local" && req.method === "POST") {
1490
1500
  const b = await readBody(req);
1491
1501
  if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
@@ -1499,6 +1509,23 @@ async function cmdApp() {
1499
1509
  if (u.pathname === "/api/mail") return json(res, mailState());
1500
1510
  if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
1501
1511
  if (u.pathname === "/api/mail/preview") { const items = sentMail(Number(u.searchParams.get("days")) || SINCE_WEEK, true); return json(res, { count: items.length, items: items.slice(0, 20) }); }
1512
+ // Screens: screenshots + named regions (screens.mjs). The image is an <img>
1513
+ // src, so it carries the token in the query (?t=), which the check above accepts.
1514
+ if (u.pathname === "/api/screens") return json(res, loadScreens().map(screenOut));
1515
+ if (u.pathname === "/api/screens/image") { const f = screenImage(u.searchParams.get("id")); if (!f) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": "image/png", "cache-control": "private, max-age=86400" }); res.end(readFileSync(f)); return; } // a screen's image never changes
1516
+ if (u.pathname === "/api/screens/capture" && req.method === "POST") { const b = await readBody(req); const d = Math.min(10, Math.max(0, Number(b.delay) || 0)); if (d) await new Promise((r) => setTimeout(r, d * 1000)); return json(res, screenOut(captureScreen(b.name))); }
1517
+ if (u.pathname === "/api/screens/import" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(importScreen(b.name, b.png))); }
1518
+ if (u.pathname === "/api/screens/regions" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(setRegions(String(b.id || ""), b.regions))); }
1519
+ if (u.pathname === "/api/screens/rename" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(renameScreen(String(b.id || ""), b.name))); }
1520
+ if (u.pathname === "/api/screens/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScreen(String(b.id || ""))); }
1521
+ // A real click on the real screen: only with the user's confirmation, after
1522
+ // the delay they picked (to bring the right window to the front).
1523
+ if (u.pathname === "/api/screens/click" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Each click needs your confirmation." }); const d = Math.min(10, Math.max(0, Number(b.delay) || 0)); if (d) await new Promise((r) => setTimeout(r, d * 1000)); return json(res, clickRegion(String(b.id || ""), String(b.region || ""))); }
1524
+ // What symbiot-desktop added (desktop.mjs): the weekly write-up, start at login.
1525
+ if (u.pathname === "/api/desktop") return json(res, { weekly: weeklyState(), autostart: autostartState() });
1526
+ if (u.pathname === "/api/desktop/weekly" && req.method === "POST") { const b = await readBody(req); return json(res, setWeekly(b)); }
1527
+ if (u.pathname === "/api/desktop/weekly/run" && req.method === "POST") return json(res, await runWeekly(produce));
1528
+ if (u.pathname === "/api/desktop/autostart" && req.method === "POST") { const b = await readBody(req); return json(res, setAutostart(!!b.on, realpathSync(fileURLToPath(import.meta.url)))); }
1502
1529
  if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await produce(cmd)); }
1503
1530
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1504
1531
  if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION) }); }
@@ -1535,6 +1562,7 @@ async function cmdApp() {
1535
1562
  });
1536
1563
  server.listen(PORT, "127.0.0.1");
1537
1564
  checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
1565
+ startWeekly(produce); // the weekly write-up + notification, when switched on in Settings
1538
1566
  }
1539
1567
 
1540
1568
  // `symbiot mail [--on|--off] [--add <path>]`: what mail it can read, and a preview.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.36.1",
3
+ "version": "0.37.0",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,8 @@
13
13
  "drift.mjs",
14
14
  "ui.mjs",
15
15
  "mail.mjs",
16
+ "screens.mjs",
17
+ "desktop.mjs",
16
18
  "README.md",
17
19
  "LICENSE"
18
20
  ],
package/screens.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // symbiot — Screens: screenshots with named regions, i.e. a blueprint of where
2
+ // things are on a screen, in screenshot pixels. The first slices of screen
3
+ // automation: it captures, stores and maps, and clicks a region's centre when
4
+ // asked (the app confirms each click). Nothing types yet.
5
+ //
6
+ // Stored in ~/.config/symbiot/screens/: <id>.png per screen, and screens.json =
7
+ // [{ id, name, w, h, ts, via, regions: [{ id, label, x, y, w, h }] }], which is
8
+ // also what an agent or script reads to find a region.
9
+ import { spawnSync } from "node:child_process";
10
+ import { join } from "node:path";
11
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, renameSync, rmSync } from "node:fs";
12
+ import { randomBytes } from "node:crypto";
13
+ import { CONFIG_DIR, hasCmd } from "./core.mjs";
14
+
15
+ const SCREENS_DIR = join(CONFIG_DIR, "screens");
16
+ const INDEX = join(SCREENS_DIR, "screens.json");
17
+ const MAX_REGIONS = 200;
18
+ const MAX_IMAGE = 40 * 1024 * 1024;
19
+ function loadScreens() { try { const a = JSON.parse(readFileSync(INDEX, "utf8")); return Array.isArray(a) ? a : []; } catch { return []; } }
20
+ function saveScreens(a) { mkdirSync(SCREENS_DIR, { recursive: true }); writeFileSync(INDEX, JSON.stringify(a, null, 2)); }
21
+ const validId = (id) => /^[a-f0-9]{12}$/.test(String(id || ""));
22
+ const screenFile = (id) => join(SCREENS_DIR, id + ".png");
23
+ // The PNG's own file, or "" (ids are checked, so a request can't reach outside the folder).
24
+ function screenImage(id) { return validId(id) && existsSync(screenFile(id)) ? screenFile(id) : ""; }
25
+
26
+ // Width and height from a PNG's IHDR chunk, or null if it isn't a PNG.
27
+ function pngSize(buf) {
28
+ if (!buf || buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a || buf.toString("ascii", 12, 16) !== "IHDR") return null;
29
+ const w = buf.readUInt32BE(16), h = buf.readUInt32BE(20);
30
+ return w && h ? { w, h } : null;
31
+ }
32
+
33
+ // Screenshot tools to try, in order, as [cmd, args] that write a PNG to `file`,
34
+ // or [cmd, args, dir] for a tool that only takes a folder (it picks the name).
35
+ // scrot and import only see X11, which on Wayland is a blank screen, so they're
36
+ // left out there.
37
+ function captureCmds(file, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland") {
38
+ if (platform === "darwin") return [["screencapture", ["-x", "-t", "png", file]]];
39
+ if (platform === "win32") {
40
+ const ps = "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b = [System.Windows.Forms.SystemInformation]::VirtualScreen; $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; $g = [System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size); $bmp.Save('" + file.replace(/'/g, "''") + "', [System.Drawing.Imaging.ImageFormat]::Png)";
41
+ return [["powershell", ["-NoProfile", "-Command", ps]]];
42
+ }
43
+ const dir = file.replace(/\.png$/, ".d");
44
+ return [["gnome-screenshot", ["-f", file]], ["spectacle", ["-b", "-n", "-f", "-o", file]], ["cosmic-screenshot", ["--interactive=false", "--modal=false", "--notify=false", "--save-dir", dir], dir], ["grim", [file]],
45
+ ["xfce4-screenshooter", ["-f", "-s", file]], ...(wayland ? [] : [["scrot", [file]], ["import", ["-window", "root", file]]])];
46
+ }
47
+ function addScreen(id, name, size, via) {
48
+ const s = { id, name: String(name || "").trim().slice(0, 80) || "Screen " + new Date().toLocaleString(), w: size.w, h: size.h, ts: Date.now(), via, regions: [] };
49
+ saveScreens([s, ...loadScreens()]);
50
+ return s;
51
+ }
52
+ // Take a screenshot of the whole screen with the first tool that works here.
53
+ function captureScreen(name) {
54
+ const id = randomBytes(6).toString("hex"), file = screenFile(id), tried = [];
55
+ try { mkdirSync(SCREENS_DIR, { recursive: true }); } catch (e) { return { error: String((e && e.message) || e) }; }
56
+ const posix = process.platform !== "win32" && process.platform !== "darwin";
57
+ for (const [cmd, args, dir] of captureCmds(file)) {
58
+ if (posix && !hasCmd(cmd)) continue;
59
+ tried.push(cmd);
60
+ if (dir) try { mkdirSync(dir, { recursive: true }); } catch {}
61
+ spawnSync(cmd, args, { stdio: "ignore", timeout: 20000, killSignal: "SIGKILL" });
62
+ if (dir) { try { const f = readdirSync(dir).find((x) => /\.png$/i.test(x)); if (f) renameSync(join(dir, f), file); } catch {} rmSync(dir, { recursive: true, force: true }); }
63
+ let size = null; try { size = pngSize(readFileSync(file)); } catch {}
64
+ if (size) return addScreen(id, name, size, cmd);
65
+ try { unlinkSync(file); } catch {}
66
+ }
67
+ return { error: tried.length ? `Couldn't take a screenshot (tried ${tried.join(", ")}). Take one yourself and use Load image.` : "No screenshot tool found. Install gnome-screenshot, spectacle, grim or scrot, or take one yourself and use Load image." };
68
+ }
69
+ // A PNG you already have (base64, or a data: URL), for when capture can't run.
70
+ function importScreen(name, data) {
71
+ const buf = Buffer.from(String(data || "").replace(/^data:[^,]*,/, ""), "base64");
72
+ if (buf.length > MAX_IMAGE) return { error: "That image is too big (40 MB at most)." };
73
+ const size = pngSize(buf); if (!size) return { error: "That isn't a PNG image." };
74
+ const id = randomBytes(6).toString("hex");
75
+ try { mkdirSync(SCREENS_DIR, { recursive: true }); writeFileSync(screenFile(id), buf); } catch (e) { return { error: String((e && e.message) || e) }; }
76
+ return addScreen(id, name, size, "loaded");
77
+ }
78
+ // Replace a screen's regions, clamped to the image and rounded to whole pixels.
79
+ function setRegions(id, regions) {
80
+ const all = loadScreens(), s = all.find((x) => x.id === id); if (!s) return { error: "not found" };
81
+ const out = [];
82
+ for (const r of (Array.isArray(regions) ? regions : []).slice(0, MAX_REGIONS)) {
83
+ if (!r) continue;
84
+ const x = Math.max(0, Math.min(s.w - 1, Math.round(Number(r.x) || 0))), y = Math.max(0, Math.min(s.h - 1, Math.round(Number(r.y) || 0)));
85
+ const w = Math.max(1, Math.min(s.w - x, Math.round(Number(r.w) || 0))), h = Math.max(1, Math.min(s.h - y, Math.round(Number(r.h) || 0)));
86
+ out.push({ id: validId(r.id) ? r.id : randomBytes(6).toString("hex"), label: String(r.label || "").trim().slice(0, 80) || "region " + (out.length + 1), x, y, w, h });
87
+ }
88
+ s.regions = out; saveScreens(all);
89
+ return s;
90
+ }
91
+ function renameScreen(id, name) {
92
+ const all = loadScreens(), s = all.find((x) => x.id === id); if (!s) return { error: "not found" };
93
+ s.name = String(name || "").trim().slice(0, 80) || s.name; saveScreens(all); return s;
94
+ }
95
+ function removeScreen(id) {
96
+ const all = loadScreens(); if (!all.some((x) => x.id === id)) return { error: "not found" };
97
+ saveScreens(all.filter((x) => x.id !== id));
98
+ try { if (validId(id)) unlinkSync(screenFile(id)); } catch {}
99
+ return { ok: true };
100
+ }
101
+ const center = (r) => ({ x: r.x + Math.floor(r.w / 2), y: r.y + Math.floor(r.h / 2) });
102
+ // What automation reads: each region with the point to aim at (its centre).
103
+ function blueprint(s) {
104
+ return { screen: s.name, size: { w: s.w, h: s.h }, regions: (s.regions || []).map((r) => ({ label: r.label, x: r.x, y: r.y, w: r.w, h: r.h, center: center(r) })) };
105
+ }
106
+
107
+ // Click tools to try, in order, as a list of [cmd, args] steps that move the
108
+ // mouse to (x, y) and left-click there. xdotool only reaches X11 windows, so on
109
+ // Wayland it's ydotool (it needs /dev/uinput). ydotool 1.x moves relative unless
110
+ // told --absolute and takes a button code; 0.1.x (Debian/Ubuntu) moves to the
111
+ // point and takes a button number.
112
+ function clickCmds(x, y, platform = process.platform, wayland = process.env.XDG_SESSION_TYPE === "wayland", ydotool1 = false) {
113
+ x = String(x); y = String(y);
114
+ if (platform === "darwin") return [[["cliclick", ["c:" + x + "," + y]]]];
115
+ if (platform === "win32") return [];
116
+ const ydo = ydotool1 ? [["ydotool", ["mousemove", "--absolute", "-x", x, "-y", y]], ["ydotool", ["click", "0xC0"]]] : [["ydotool", ["mousemove", x, y]], ["ydotool", ["click", "1"]]];
117
+ const xdo = [["xdotool", ["mousemove", "--sync", x, y, "click", "1"]]];
118
+ return wayland ? [ydo] : [xdo, ydo];
119
+ }
120
+ const ydotoolIs1 = () => /absolute/i.test(String(spawnSync("ydotool", ["mousemove", "--help"], { encoding: "utf8", timeout: 5000 }).stdout || ""));
121
+ // The size of the screen in the units the click tool uses, when it can be read:
122
+ // on macOS screencapture saves Retina pixels but cliclick takes points.
123
+ function clickSpace(tool) {
124
+ const run = (cmd, args) => String(spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 }).stdout || "");
125
+ const m = tool === "xdotool" ? run("xdotool", ["getdisplaygeometry"]).match(/(\d+)\s+(\d+)/)
126
+ : tool === "cliclick" ? run("osascript", ["-l", "JavaScript", "-e", "ObjC.import('AppKit'); var f = $.NSScreen.screens.objectAtIndex(0).frame; f.size.width + ' ' + f.size.height"]).match(/(\d+)\s+(\d+)/) : null;
127
+ return m && +m[1] && +m[2] ? { w: +m[1], h: +m[2] } : null;
128
+ }
129
+ // Move the mouse to a region's centre and click it. The caller confirms first:
130
+ // it clicks whatever is at that spot on the screen right now.
131
+ function clickRegion(id, regionId) {
132
+ const s = loadScreens().find((x) => x.id === id); if (!s) return { error: "not found" };
133
+ const r = (s.regions || []).find((x) => x.id === regionId); if (!r) return { error: "That region is gone. Reload the screen." };
134
+ if (process.platform === "win32") return { error: "Clicking isn't supported on Windows yet." };
135
+ const c = center(r), wayland = process.env.XDG_SESSION_TYPE === "wayland";
136
+ // The first tool that's installed does it. If that fails it says so, rather
137
+ // than trying the next one: a half-done move + click could otherwise click twice.
138
+ const tool = clickCmds(c.x, c.y).map((st) => st[0][0]).find((t) => hasCmd(t));
139
+ if (tool) {
140
+ const sp = clickSpace(tool), x = sp ? Math.round(c.x * sp.w / s.w) : c.x, y = sp ? Math.round(c.y * sp.h / s.h) : c.y;
141
+ const steps = clickCmds(x, y, process.platform, wayland, tool === "ydotool" && ydotoolIs1()).find((st) => st[0][0] === tool);
142
+ for (const [cmd, args] of steps) {
143
+ const p = spawnSync(cmd, args, { encoding: "utf8", timeout: 10000, killSignal: "SIGKILL" });
144
+ if (p.status !== 0) return { error: `${tool} couldn't click: ` + (String(p.stderr || (p.error && p.error.message) || "").trim().split("\n").filter((l) => l && !/notice:/.test(l)).pop() || "exit " + p.status) };
145
+ }
146
+ return { ok: true, label: r.label, x, y, via: tool };
147
+ }
148
+ const want = process.platform === "darwin" ? "cliclick (brew install cliclick)" : wayland ? "ydotool" : "xdotool or ydotool";
149
+ return { error: `No click tool found. Install ${want}.` };
150
+ }
151
+
152
+ export { loadScreens, screenImage, pngSize, captureCmds, captureScreen, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickCmds, clickRegion };
package/ui.mjs CHANGED
@@ -124,6 +124,11 @@ label.check input{width:auto}
124
124
  .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
125
125
  .lg{display:inline-flex;gap:6px;align-items:center}
126
126
  .lg i{width:10px;height:10px;border-radius:50%;display:inline-block}
127
+ .scrwrap{position:relative;margin-top:10px;border:1px solid var(--line);border-radius:10px;overflow:hidden;cursor:crosshair;user-select:none;touch-action:none;background:var(--ink2)}
128
+ .scrwrap img{display:block;width:100%;height:auto;pointer-events:none}
129
+ .scrbox{position:absolute;border:2px solid var(--green);background:rgba(61,220,151,.12);pointer-events:none}
130
+ .scrbox span{position:absolute;left:0;top:0;font-size:11px;background:var(--green);color:var(--ink);padding:1px 6px;white-space:nowrap}
131
+ .scrbox.draw{border:2px dashed var(--amber);background:rgba(242,165,65,.14)}
127
132
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
128
133
  </style></head><body>
129
134
  <div id="updatebar" class="updatebar"></div>
@@ -158,6 +163,14 @@ label.check input{width:auto}
158
163
  <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
159
164
  </div>
160
165
  <div id="review" class="review hidden"></div>
166
+ <div style="margin-top:22px;border-top:1px solid var(--line);padding-top:4px">
167
+ <div class="tgroup">Screens <span class="tcount">experimental &middot; blueprints for screen automation</span></div>
168
+ <div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Nothing types yet.</div>
169
+ <div class="row" style="margin-top:10px"><input id="screenname" placeholder="name the screen, e.g. GitHub PR page" style="flex:1"><select id="screendelay" title="wait first, so you can bring the right window to the front" style="flex:0 0 auto;width:auto"><option value="0">now</option><option value="3">in 3s</option><option value="5">in 5s</option><option value="10">in 10s</option></select><button class="ghost" id="capture">Capture screen</button><button class="ghost" id="screenload" title="use a PNG screenshot you already have">Load image</button><input type="file" id="screenfile" accept="image/png" class="hidden"></div>
170
+ <div id="screenmsg"></div>
171
+ <div id="screenlist" class="taskfilter"></div>
172
+ <div id="screenview"></div>
173
+ </div>
161
174
  </section>
162
175
  <section id="panel-run" class="hidden">
163
176
  <div class="row"><button class="act" id="write">Write my <span id="what">week</span></button>
@@ -231,6 +244,13 @@ label.check input{width:auto}
231
244
  <div id="mailout"></div>
232
245
  </div>
233
246
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
247
+ <label>Weekly write-up and start at login</label>
248
+ <label class="check"><input type="checkbox" id="weeklyon"> Write my week and send me a desktop notification every</label>
249
+ <div class="row" style="margin-top:2px"><select id="weeklyday" style="width:auto"><option value="1">Monday</option><option value="2">Tuesday</option><option value="3">Wednesday</option><option value="4">Thursday</option><option value="5">Friday</option><option value="6">Saturday</option><option value="0">Sunday</option></select><span class="muted">at</span><select id="weeklyhour" style="width:auto"></select><button class="ghost" id="weeklynow" title="write it now and send the notification, to check it works">Write it now</button></div>
250
+ <label class="check"><input type="checkbox" id="autostart"> Start Symbiot in the background when I log in (no window)</label>
251
+ <div class="note muted" id="desktopnote"></div>
252
+ </div>
253
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
234
254
  <label>Local models <span class="muted">(experimental)</span></label>
235
255
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
236
256
  <button class="ghost" id="setuplocal" style="margin-left:8px">Set up a free local model</button>
@@ -257,7 +277,7 @@ $('panel-settings').classList.toggle('hidden',!isSet);
257
277
  $('panel-tasks').classList.toggle('hidden',!isTasks);
258
278
  $('panel-drift').classList.toggle('hidden',!isDrift);
259
279
  $('panel-agents').classList.toggle('hidden',!isAgents);
260
- if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
280
+ if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');$('outfoot').style.display='none';if(tab==='week')showLatestWeek();}
261
281
  if(isMap&&!mapLoaded)loadMap();
262
282
  if(isTasks){fillTaskRepos();loadTasks();}
263
283
  if(isDrift&&!driftLoaded)loadDrift();
@@ -377,14 +397,24 @@ var keys=Object.keys(groups).sort(function(a,b){var ia=TORDER.indexOf(a),ib=TORD
377
397
  var h="";keys.forEach(function(ty){h+="<div class='tgroup'>"+esc(ty)+" <span class='tcount'>"+groups[ty].length+"</span></div>";groups[ty].forEach(function(t){h+=taskRow(t,false);});});
378
398
  el.innerHTML=h;wireTaskRows(el);}
379
399
  function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});loadPending();});}
380
- function loadPending(){api('/api/pending').then(function(list){var el=document.getElementById('reviewlist');PENDTASKS=[];if(!list||!list.length){el.innerHTML='';return;}
400
+ // While an agent is still working in a repo on the review list, its Approve is
401
+ // greyed out; check again every few seconds, and when it finishes, sync (its last
402
+ // ticks land in review) so Approve unlocks without reopening the tab. The poll
403
+ // only re-renders on a change, so an open diff or chat isn't closed under you.
404
+ var pendTimer=null,PENDRUN='';
405
+ function pendRunning(list){return (list||[]).filter(function(r){return r.running;}).map(function(r){return r.repo;}).join('\\n');}
406
+ function pendPoll(){if(pendTimer){clearTimeout(pendTimer);pendTimer=null;}if(!PENDRUN||current!=='tasks')return;
407
+ pendTimer=setTimeout(function(){pendTimer=null;if(current!=='tasks')return;api('/api/pending').then(function(list){if(pendRunning(list)!==PENDRUN)loadTasks();else pendPoll();}).catch(pendPoll);},4000);}
408
+ function loadPending(){api('/api/pending').then(function(list){var el=document.getElementById('reviewlist');PENDTASKS=[];PENDRUN=pendRunning(list);pendPoll();
409
+ if(!list||!list.length){el.innerHTML='';return;}
381
410
  var n=0;list.forEach(function(r){n+=r.tasks.length||1;});
382
411
  var h="<div class='tgroup' style='color:var(--amber)'>Awaiting your review <span class='tcount'>"+n+"</span></div>";
383
412
  list.forEach(function(r){var ch=r.files.length?(r.files.length+" file"+(r.files.length>1?"s":"")+" changed"+(r.stat?" &middot; "+esc(r.stat):"")):"no uncommitted changes";
384
413
  h+="<div class='rcard' data-repo='"+esc(r.repo)+"'><div class='rhead'><b>"+esc(r.repo)+"</b><span class='muted'>"+(r.path?"on "+esc(r.branch||'?')+" &middot; "+ch:"repo not found on disk")+"</span></div>";
385
414
  r.tasks.forEach(function(t){PENDTASKS.push(t);h+="<div class='task' data-id='"+esc(t.id)+"'><span class='t'>"+esc(t.text)+"</span>"+askBtn(t)+"<button class='rm sendback' title='not right - send back to the agent (unticks it)'>&#8630;</button></div>";});
386
415
  if(r.untasked)h+="<div class='muted' style='margin:6px 0'>Uncommitted changes with no ticked task behind them. Check the diff before you approve.</div>";
387
- h+="<div class='row'><button class='act approve'"+(r.path?"":" disabled")+(r.untasked?" data-untasked='1' title='commit on a branch, push and open a PR, without a task'>Approve changes without a task &rarr; PR":" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive"))+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
416
+ if(r.running)h+="<div class='muted' style='margin:6px 0'>&#9203; Agent still working: its changes may be half done. Approve unlocks when it finishes.</div>";
417
+ h+="<div class='row'><button class='act approve'"+(r.path&&!r.running?"":" disabled")+(r.running?" title='the agent is still editing this repo'>"+(r.untasked?"Approve changes without a task":"Approve")+" &middot; agent still working":r.untasked?" data-untasked='1' title='commit on a branch, push and open a PR, without a task'>Approve changes without a task &rarr; PR":" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive"))+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
388
418
  el.innerHTML=h;
389
419
  el.querySelectorAll('.rcard').forEach(function(card){var repo=card.getAttribute('data-repo');
390
420
  card.querySelectorAll('.task').forEach(function(row){wireAsk(row,row.getAttribute('data-id'));});
@@ -449,7 +479,11 @@ if(answering()){stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=s
449
479
  saveDrafts(el);AGENTLIST=list;
450
480
  el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
451
481
  var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
482
+ if(a.fromHeld)st+=" &middot; started on the tasks held for the last run";
452
483
  if(nQs(a))st+=" &middot; <span style='color:var(--amber)'>needs your answers</span>";
484
+ if(a.held!=null){var hn=a.held.length,ht=a.held.map(function(t){return "&bull; "+esc(t).replace(/'/g,'&#39;');}).join('&#10;');
485
+ ht+=(hn?'&#10;&#10;':'')+(a.status==='running'?"Sent while this agent was running. They wait in .symbiot/TASKS.next.md, replace TASKS.md when it finishes (keeping its ticks), and an agent starts on them then.":"They wait in .symbiot/TASKS.next.md for the agent running in this folder to finish. After that, an agent starts on them the next time the Tasks tab checks this repo, or send again.");
486
+ st+=" &middot; <span style='color:var(--amber);cursor:help' title='"+ht+"'>&#9208; "+(hn?hn+" task"+(hn>1?"s":"")+" held":"tasks held")+"</span>";}
453
487
  var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
454
488
  if(a.status==='running')b+="<div class='bar'><i></i></div>";
455
489
  var ch=a.changed;
@@ -489,7 +523,7 @@ if(r.empty){btn.disabled=false;o.innerHTML="<div class='muted' style='margin-top
489
523
  var n=(r.written||[]).length;var opens=[];var h="<div class='drift' style='margin-top:10px'><div class='dh'><span class='dot-c'></span><span class='dn'>Done &mdash; wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
490
524
  if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>&#10003; <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span>"+(w.held?" <span class='ev'>&#9208; held: an agent is still running there, so these land when it finishes</span>":"")+"</li>";});h+="</ul>";}
491
525
  if(r.unresolved&&r.unresolved.length){var names=r.unresolved.map(function(u){return u.name;}).join(", ");var hasNoRepo=r.unresolved.some(function(u){return u.name==='(no repo)';});h+="<div class='dd' style='margin-top:6px'>&#9888; not sent: "+esc(names)+". "+(hasNoRepo?"Pick a repo in the dropdown next to <b>Add</b> so the task has somewhere to go.":"That repo isn't in the map &mdash; add its folder in Settings.")+"</div>";}
492
- if(r.handoff&&r.written&&r.written.length){opens=r.written.map(function(w){return api('/api/open',{path:w.path}).then(function(x){if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="&#9888; <b>"+esc(w.name)+"</b> already has an agent running, so another wasn&#39;t started. Its new tasks are held until it finishes; send again then to start one on them.";o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
526
+ if(r.handoff&&r.written&&r.written.length){opens=r.written.map(function(w){return api('/api/open',{path:w.path}).then(function(x){if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="&#9888; <b>"+esc(w.name)+"</b> already has an agent running, so another wasn&#39;t started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
493
527
  else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send (and watch it in the Agents tab). For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
494
528
  h+="</div>";
495
529
  o.innerHTML=h;
@@ -552,6 +586,74 @@ function loadMap(){var p=document.getElementById("profile");p.textContent="Mappi
552
586
  function poll(){if(done)return;api('/api/scan').then(function(s){if(done)return;if(s&&s.active&&s.phase)p.textContent=scanLine(s);setTimeout(poll,600);}).catch(function(){});}
553
587
  setTimeout(poll,400);
554
588
  api("/api/map").then(function(g){done=true;mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;fillTaskRepos();layout(g.nodes,g.edges);view={k:1,x:0,y:0};p.innerHTML=profileLine(g)+(g.stats&&g.stats.partial?" &middot; <span class='err'>partial &mdash; the scan hit its time limit</span>":"");render();});}
589
+ // Screens: a screenshot plus named regions, the blueprint (screens.mjs). Drag on
590
+ // the image to mark a region; coordinates are the screenshot's own pixels.
591
+ // Names stay out of attributes (esc() doesn't escape quotes): rows carry indexes.
592
+ var SCREENS=[],SCREEN=null,SDRAG=null,SPEND=null,SCLICKDELAY=3;
593
+ function loadScreensUI(){api('/api/screens').then(function(list){SCREENS=list||[];var id=SCREEN&&SCREEN.id;SCREEN=null;SCREENS.forEach(function(s){if(s.id===id)SCREEN=s;});renderScreenList();renderScreen();});}
594
+ function renderScreenList(){var box=$('screenlist');if(!SCREENS.length){box.innerHTML="<span class='muted' style='font-size:12px'>No screens yet.</span>";return;}
595
+ box.innerHTML="<span class='fl'>Screens</span>"+SCREENS.map(function(s,i){return "<button class='fchip"+(SCREEN&&SCREEN.id===s.id?" on":"")+"' data-i='"+i+"'>"+esc(s.name)+" <span class='tcount'>"+(s.regions||[]).length+"</span></button>";}).join('');
596
+ box.querySelectorAll('.fchip').forEach(function(b){b.addEventListener('click',function(){var s=SCREENS[+b.getAttribute('data-i')];SCREEN=(SCREEN&&s&&SCREEN.id===s.id)?null:s;SPEND=null;renderScreenList();renderScreen();});});}
597
+ function scrPct(v,of){return (v/of*100).toFixed(3)+'%';}
598
+ function scrBox(r,s,cls){return "<div class='scrbox"+(cls?" "+cls:"")+"' style='left:"+scrPct(r.x,s.w)+";top:"+scrPct(r.y,s.h)+";width:"+scrPct(r.w,s.w)+";height:"+scrPct(r.h,s.h)+"'>"+(r.label?"<span>"+esc(r.label)+"</span>":"")+"</div>";}
599
+ function renderScreen(){var v=$('screenview'),s=SCREEN;if(!s){v.innerHTML='';return;}
600
+ var h="<div class='row' style='margin-top:10px'><input id='scrname' title='rename this screen' style='flex:1'><span class='muted'>"+s.w+" &times; "+s.h+" px</span><button class='ghost' id='scrcopy' title='copy the regions and their coordinates as JSON'>Copy blueprint</button><button class='ghost' id='scrdel'>Delete</button></div>";
601
+ h+="<div class='scrwrap' id='scrwrap'><img src='/api/screens/image?id="+encodeURIComponent(s.id)+"&t="+encodeURIComponent(T)+"' alt='' draggable='false'>"+(s.regions||[]).map(function(r){return scrBox(r,s,'');}).join('')+(SPEND?scrBox(SPEND,s,'draw'):"")+"<div class='scrbox draw hidden' id='scrdraw'></div></div>";
602
+ h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region &middot; coordinates are screenshot pixels</div>";
603
+ h+="<div class='row"+(SPEND?"":" hidden")+"' id='scrlabel' style='margin-top:8px'><input id='scrlabelin' placeholder='name this region, e.g. Merge button' style='flex:1'><button class='act' id='scrlabelok'>Add region</button><button class='ghost' id='scrlabelno'>Cancel</button></div>";
604
+ var rs=(s.blueprint&&s.blueprint.regions)||[];
605
+ if(rs.length)h+="<div class='row' style='margin-top:8px'><span class='muted' style='font-size:12px;flex:1'><b>Click here</b> moves your mouse to a region's centre and clicks, on your real screen. It asks first, every time.</span><select id='scrclickdelay' title='wait first, so you can bring the right window to the front' style='flex:0 0 auto;width:auto'>"+[0,3,5,10].map(function(d){return "<option value='"+d+"'"+(d===SCLICKDELAY?" selected":"")+">"+(d?"click in "+d+"s":"click now")+"</option>";}).join('')+"</select></div>";
606
+ h+="<div id='scrregions'>"+(rs.length?rs.map(function(r,i){return "<div class='task' data-i='"+i+"'><span class='t'><b>"+esc(r.label)+"</b> <span class='muted' style='font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px'>x "+r.x+", y "+r.y+" &middot; "+r.w+"&times;"+r.h+" &middot; centre ("+r.center.x+", "+r.center.y+")</span></span><button class='ghost scrclick' title='move the mouse to the centre of this region and click there'>Click here</button><button class='rm scrrm' title='remove this region'>&times;</button></div>";}).join(''):"<div class='muted' style='font-size:12px;margin-top:8px'>No regions yet.</div>")+"</div>";
607
+ v.innerHTML=h;wireScreen();}
608
+ function scrPoint(ev){var rc=$('scrwrap').querySelector('img').getBoundingClientRect(),s=SCREEN;if(!rc.width||!rc.height)return {x:0,y:0};return {x:Math.max(0,Math.min(s.w-1,Math.round((ev.clientX-rc.left)/rc.width*s.w))),y:Math.max(0,Math.min(s.h-1,Math.round((ev.clientY-rc.top)/rc.height*s.h)))};}
609
+ function scrRect(a,b){return {x:Math.min(a.x,b.x),y:Math.min(a.y,b.y),w:Math.abs(b.x-a.x),h:Math.abs(b.y-a.y)};}
610
+ function screenErr(msg){$('screenmsg').innerHTML="<div class='note err'>"+esc(msg)+"</div>";}
611
+ function screenSaved(s){if(!s||s.error){screenErr((s&&s.error)||'could not save');return;}$('screenmsg').innerHTML='';SCREENS=SCREENS.map(function(x){return x.id===s.id?s:x;});SCREEN=s;SPEND=null;renderScreenList();renderScreen();}
612
+ function saveRegions(regions){api('/api/screens/regions',{id:SCREEN.id,regions:regions}).then(screenSaved);}
613
+ function wireScreen(){var s=SCREEN,wrap=$('scrwrap'),bar=$('scrbar'),draw=$('scrdraw'),nm=$('scrname');
614
+ nm.value=s.name;
615
+ nm.addEventListener('change',function(){var n=(nm.value||'').trim();if(n)api('/api/screens/rename',{id:s.id,name:n}).then(screenSaved);});
616
+ $('scrcopy').addEventListener('click',function(){var b=$('scrcopy');navigator.clipboard.writeText(JSON.stringify(s.blueprint,null,2));b.textContent='Copied';setTimeout(function(){b.textContent='Copy blueprint';},1400);});
617
+ $('scrdel').addEventListener('click',function(){if(typeof confirm==='function'&&!confirm('Delete this screen and its regions?'))return;api('/api/screens/remove',{id:s.id}).then(function(){SCREEN=null;SPEND=null;loadScreensUI();});});
618
+ wrap.addEventListener('pointerdown',function(ev){if(ev.button)return;ev.preventDefault();SDRAG=scrPoint(ev);try{wrap.setPointerCapture(ev.pointerId);}catch(e){}});
619
+ wrap.addEventListener('pointermove',function(ev){var p=scrPoint(ev);if(!SDRAG){bar.innerHTML='<b>x '+p.x+', y '+p.y+'</b> &middot; drag to mark a region';return;}
620
+ var r=scrRect(SDRAG,p);bar.innerHTML='<b>x '+r.x+', y '+r.y+' &middot; '+r.w+'&times;'+r.h+'</b>';draw.classList.remove('hidden');draw.style.left=scrPct(r.x,s.w);draw.style.top=scrPct(r.y,s.h);draw.style.width=scrPct(r.w,s.w);draw.style.height=scrPct(r.h,s.h);});
621
+ wrap.addEventListener('pointerup',function(ev){if(!SDRAG)return;var r=scrRect(SDRAG,scrPoint(ev));SDRAG=null;if(r.w<4||r.h<4){draw.classList.add('hidden');return;}SPEND=r;renderScreen();$('scrlabelin').focus();});
622
+ function addPending(){if(!SPEND)return;saveRegions((s.regions||[]).concat([{label:($('scrlabelin').value||'').trim(),x:SPEND.x,y:SPEND.y,w:SPEND.w,h:SPEND.h}]));}
623
+ $('scrlabelok').addEventListener('click',addPending);
624
+ $('scrlabelin').addEventListener('keydown',function(e){if(e.key==='Enter')addPending();else if(e.key==='Escape'){SPEND=null;renderScreen();}});
625
+ $('scrlabelno').addEventListener('click',function(){SPEND=null;renderScreen();});
626
+ $('scrregions').querySelectorAll('.scrrm').forEach(function(b){b.addEventListener('click',function(){var i=+b.closest('.task').getAttribute('data-i');saveRegions((s.regions||[]).filter(function(r,j){return j!==i;}));});});
627
+ var cd=$('scrclickdelay');if(cd)cd.addEventListener('change',function(){SCLICKDELAY=+cd.value||0;});
628
+ $('scrregions').querySelectorAll('.scrclick').forEach(function(b){b.addEventListener('click',function(){clickUI(s,+b.closest('.task').getAttribute('data-i'),b);});});}
629
+ // A real click: confirmed every time (no confirm() to ask with means no click).
630
+ function clickUI(s,i,b){var r=(s.regions||[])[i],c=s.blueprint&&s.blueprint.regions[i];if(!r||!c)return;var d=SCLICKDELAY;
631
+ var q="Move your mouse to “"+r.label+"” at ("+c.center.x+", "+c.center.y+") and click there"+(d?" in "+d+" seconds":" now")+"?\\n\\nIt clicks whatever is at that spot on your screen"+(d?" by then, so bring the right window to the front.":".")+(s.via==='loaded'?"\\n\\nThis screen was loaded from an image: check it matches your screen now.":"");
632
+ if(typeof confirm!=='function'||!confirm(q))return;
633
+ b.disabled=true;$('screenmsg').innerHTML="<div class='note muted'>"+(d?"Clicking “"+esc(r.label)+"” in "+d+"s &mdash; bring the right window to the front&hellip;":"Clicking&hellip;")+"</div>";
634
+ api('/api/screens/click',{id:s.id,region:r.id,delay:d,confirmed:true}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
635
+ $('screenmsg').innerHTML="<div class='note ok'>&#10003; Clicked “"+esc(x.label)+"” at ("+x.x+", "+x.y+") with "+esc(x.via)+".</div>";}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});}
636
+ function screenAdded(s){if(!s||s.error){screenErr((s&&s.error)||'failed');return;}$('screenmsg').innerHTML='';$('screenname').value='';SCREEN=s;SPEND=null;loadScreensUI();}
637
+ function captureUI(){var b=$('capture'),d=+($('screendelay').value||0);b.disabled=true;
638
+ $('screenmsg').innerHTML="<div class='note muted'>"+(d?"Capturing in "+d+"s &mdash; bring the window you want to the front&hellip;":"Capturing&hellip;")+"</div>";
639
+ api('/api/screens/capture',{name:$('screenname').value||'',delay:d}).then(function(s){b.disabled=false;screenAdded(s);}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});}
640
+ function pickImageUI(){var f=$('screenfile');if(f&&f.click)f.click();}
641
+ function importUI(){var f=$('screenfile'),file=f&&f.files&&f.files[0];if(!file)return;$('screenmsg').innerHTML="<div class='note muted'>Loading&hellip;</div>";
642
+ var rd=new FileReader();rd.onload=function(){api('/api/screens/import',{name:$('screenname').value||file.name.replace(/[.]png$/i,''),png:rd.result}).then(function(s){f.value='';screenAdded(s);});};rd.readAsDataURL(file);}
643
+ // Weekly write-up + start at login (desktop.mjs). The Week tab shows the latest
644
+ // write-up until you write a new one.
645
+ function renderDesktop(d){if(!d)return;var w=d.weekly||{},a=d.autostart||{};$('weeklyon').checked=!!w.on;$('weeklyday').value=String(w.day);$('weeklyhour').value=String(w.hour);$('autostart').checked=!!a.on;
646
+ var n=[];if(d.error)n.push("<span class='err'>"+esc(d.error)+"</span>");
647
+ if(w.latest)n.push("Latest write-up: "+esc(new Date(w.latest.at).toLocaleString())+" &middot; <span style='font-family:ui-monospace,monospace'>"+esc(w.latest.file)+"</span> (also in the Week tab)");
648
+ n.push(a.on?"Starts at login from <span style='font-family:ui-monospace,monospace'>"+esc(a.file)+"</span>. Run <b>symbiot app</b> to open its window.":"The weekly write-up happens while Symbiot is running, so start it at login to have it every week. A week missed while the computer was off is written when Symbiot next starts.");
649
+ $('desktopnote').innerHTML=n.join('<br>');}
650
+ function loadDesktop(err){api('/api/desktop').then(function(d){if(d&&err)d.error=err;renderDesktop(d);});}
651
+ function saveWeekly(){api('/api/desktop/weekly',{on:$('weeklyon').checked,day:$('weeklyday').value,hour:$('weeklyhour').value}).then(function(){loadDesktop();});}
652
+ function saveAutostart(){api('/api/desktop/autostart',{on:$('autostart').checked}).then(function(a){loadDesktop(a&&a.error);});}
653
+ function weeklyNow(){var b=$('weeklynow');b.disabled=true;b.textContent='Writing...';
654
+ api('/api/desktop/weekly/run',{}).then(function(r){b.disabled=false;b.textContent='Write it now';loadDesktop(r&&r.error?(r.error==='not-connected'?'Connect an AI above first.':r.error):'');});}
655
+ function showLatestWeek(){api('/api/desktop').then(function(d){var l=d&&d.weekly&&d.weekly.latest;if(!l||current!=='week'||$('out').textContent!=='Nothing yet - hit the button.')return;
656
+ $('out').textContent=l.text;$('out').classList.remove('muted');$('copy').classList.remove('hidden');var f=$('outfoot');f.textContent='Written automatically '+new Date(l.at).toLocaleString()+' · '+l.file+(l.footer?' · '+l.footer:'');f.style.display='block';});}
555
657
  function fitBadge(m){return m.fits?"<span class='tag'>fits your RAM</span>":"<span class='tag' style='background:#3a2a12;border-color:#6b4a1f;color:#F2A541'>needs more RAM</span>";}
556
658
  function loadRec(){var out=document.getElementById('recout');out.innerHTML="<div class='muted'>Reading your hardware...</div>";
557
659
  api('/api/models').then(function(d){var hw=d.hardware,rec=d.rec;
@@ -582,6 +684,15 @@ document.getElementById('addmail').addEventListener('click',addMailUI);
582
684
  document.getElementById('newmail').addEventListener('keydown',function(e){if(e.key==='Enter')addMailUI();});
583
685
  document.getElementById('mailpreview').addEventListener('click',previewMail);
584
686
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
687
+ document.getElementById('capture').addEventListener('click',captureUI);
688
+ document.getElementById('screenload').addEventListener('click',pickImageUI);
689
+ document.getElementById('screenfile').addEventListener('change',importUI);
690
+ document.getElementById('weeklyon').addEventListener('change',saveWeekly);
691
+ document.getElementById('weeklyday').addEventListener('change',saveWeekly);
692
+ document.getElementById('weeklyhour').addEventListener('change',saveWeekly);
693
+ document.getElementById('weeklynow').addEventListener('click',weeklyNow);
694
+ document.getElementById('autostart').addEventListener('change',saveAutostart);
695
+ (function(){var h='';for(var i=0;i<24;i++)h+="<option value='"+i+"'>"+(i<10?'0':'')+i+":00</option>";document.getElementById('weeklyhour').innerHTML=h;})();
585
696
  var SRV_STARTED=null,srvDown=false,updBusy=false;
586
697
  function ubar(){return document.getElementById('updatebar');}
587
698
  function heartbeat(fresh){
@@ -614,5 +725,5 @@ function heartbeat(fresh){
614
725
  function doUpdate(){updBusy=true;try{localStorage.setItem('symbiot_update_tried',document.getElementById('ver').textContent.replace(/^v/,''));}catch(e){}var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
615
726
  setInterval(heartbeat,4000);heartbeat(true);
616
727
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
617
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadMail();
728
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadMail();loadScreensUI();loadDesktop();
618
729
  </script></body></html>`;