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 +60 -5
- package/agents.mjs +14 -5
- package/desktop.mjs +132 -0
- package/index.mjs +37 -9
- package/package.json +3 -1
- package/screens.mjs +152 -0
- package/ui.mjs +116 -5
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
|
|
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.
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
//
|
|
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)).
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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,
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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 · 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
|
-
|
|
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?" · "+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||'?')+" · "+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)'>↶</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='
|
|
416
|
+
if(r.running)h+="<div class='muted' style='margin:6px 0'>⏳ 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")+" · 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 → PR":" title='commit on a branch, push, open a PR, then archive'>Approve → "+(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&¤t==='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 · '+fmtE(a.elapsed)):(esc(a.status)+' · '+fmtE(a.elapsed)+(a.exitCode!=null?' · exit '+a.exitCode:''));
|
|
482
|
+
if(a.fromHeld)st+=" · started on the tasks held for the last run";
|
|
452
483
|
if(nQs(a))st+=" · <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 "• "+esc(t).replace(/'/g,''');}).join(' ');
|
|
485
|
+
ht+=(hn?' ':'')+(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+=" · <span style='color:var(--amber);cursor:help' title='"+ht+"'>⏸ "+(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 — wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
|
|
490
524
|
if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>✓ <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span>"+(w.held?" <span class='ev'>⏸ 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'>⚠ 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 — 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="⚠ <b>"+esc(w.name)+"</b> already has an agent running, so another wasn't started. Its new tasks are held until it finishes
|
|
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="⚠ <b>"+esc(w.name)+"</b> already has an agent running, so another wasn'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'>🤖 Handed "+n+" repo(s) to your agent — opening the <b>Agents</b> tab to watch it work…</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?" · <span class='err'>partial — 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+" × "+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 · 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+" · "+r.w+"×"+r.h+" · 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'>×</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> · drag to mark a region';return;}
|
|
620
|
+
var r=scrRect(SDRAG,p);bar.innerHTML='<b>x '+r.x+', y '+r.y+' · '+r.w+'×'+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 — bring the right window to the front…":"Clicking…")+"</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'>✓ 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 — bring the window you want to the front…":"Capturing…")+"</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…</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())+" · <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>`;
|