symbiot 0.41.7 → 0.42.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/links.mjs ADDED
@@ -0,0 +1,149 @@
1
+ // symbiot — Links: one button per standard work site (Gmail, Outlook, GitHub,
2
+ // Slack, Teams, Jira…), so linking your work is a click, not a list of sites to
3
+ // type in one by one. Linking a site does three things at once:
4
+ // - opens it in Symbiot's browser (headless.mjs signIn), where you sign in the
5
+ // usual way, with the site's own login, SSO and 2FA (Symbiot never sees a password);
6
+ // - trusts its domains (Screens' Press and Type go ahead there without asking);
7
+ // - watches its inbox or notifications page (watch.mjs), so what arrives there
8
+ // reaches the Dashboard, Standup and Week: your week, from all of your work.
9
+ // Whether a link works is read from its watch: signed in, signed out, or still
10
+ // waiting for you to sign in.
11
+ //
12
+ // For a whole company, links.json (in Symbiot's config folder, or the file
13
+ // SYMBIOT_LINKS names) adds the company's own sites and hides the ones it
14
+ // doesn't use, so everyone gets the same buttons:
15
+ // { "links": [{ "id": "jira", "name": "Jira", "group": "Work", "url": "https://acme.atlassian.net/jira/your-work", "hosts": ["acme.atlassian.net"] }],
16
+ // "hide": ["whatsapp"] }
17
+ // An entry with a built-in's id replaces it. Linking stays in the app: like
18
+ // trusted sites, there's no command an agent could call to link a site itself.
19
+ //
20
+ // What's linked is config.linked: { <id>: { at, watch?, hosts: [trusted by the link] } }.
21
+ import { join } from "node:path";
22
+ import { readFileSync } from "node:fs";
23
+ import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
24
+ import { siteUrl, signIn, readPage, trustedSites, trustSite, untrustSite } from "./headless.mjs";
25
+ import { watchState, addWatch, removeWatch, checkWatch, GITHUB_INBOX } from "./watch.mjs";
26
+
27
+ // The standard sites. url is where you sign in and, with watch, the page that's
28
+ // read for what's new; hosts are trusted along with it.
29
+ const CATALOG = [
30
+ { id: "gmail", name: "Gmail", group: "Mail", url: "https://mail.google.com/mail/u/0/#inbox", hosts: ["mail.google.com"], watch: true },
31
+ { id: "outlook", name: "Outlook", group: "Mail", url: "https://outlook.office.com/mail/", hosts: ["outlook.office.com", "outlook.office365.com", "outlook.live.com"], watch: true },
32
+ { id: "gcal", name: "Google Calendar", group: "Calendar", url: "https://calendar.google.com/calendar/r/agenda", hosts: ["calendar.google.com"], watch: true },
33
+ { id: "outlookcal", name: "Outlook Calendar", group: "Calendar", url: "https://outlook.office.com/calendar/view/agenda", hosts: ["outlook.office.com", "outlook.office365.com"], watch: true },
34
+ { id: "github", name: "GitHub", group: "Code", url: GITHUB_INBOX, hosts: ["github.com"], watch: true },
35
+ { id: "gitlab", name: "GitLab", group: "Code", url: "https://gitlab.com/dashboard/todos", hosts: ["gitlab.com"], watch: true },
36
+ { id: "slack", name: "Slack", group: "Chat", url: "https://app.slack.com/client", hosts: ["app.slack.com"], watch: true },
37
+ { id: "teams", name: "Microsoft Teams", group: "Chat", url: "https://teams.microsoft.com/", hosts: ["teams.microsoft.com"], watch: true },
38
+ { id: "whatsapp", name: "WhatsApp", group: "Chat", url: "https://web.whatsapp.com/", hosts: ["web.whatsapp.com"], watch: true },
39
+ { id: "jira", name: "Jira & Confluence", group: "Work", url: "https://home.atlassian.com/", hosts: ["atlassian.net", "home.atlassian.com"], watch: true },
40
+ { id: "linear", name: "Linear", group: "Work", url: "https://linear.app/", hosts: ["linear.app"], watch: true },
41
+ { id: "asana", name: "Asana", group: "Work", url: "https://app.asana.com/", hosts: ["app.asana.com"], watch: true },
42
+ { id: "trello", name: "Trello", group: "Work", url: "https://trello.com/", hosts: ["trello.com"] },
43
+ { id: "gdrive", name: "Google Drive", group: "Docs", url: "https://drive.google.com/drive/recent", hosts: ["drive.google.com", "docs.google.com"] },
44
+ { id: "notion", name: "Notion", group: "Docs", url: "https://www.notion.so/", hosts: ["notion.so"] },
45
+ { id: "hubspot", name: "HubSpot", group: "Sales", url: "https://app.hubspot.com/", hosts: ["app.hubspot.com"] },
46
+ { id: "salesforce", name: "Salesforce", group: "Sales", url: "https://login.salesforce.com/", hosts: ["salesforce.com", "lightning.force.com"] },
47
+ ];
48
+ const GROUPS = ["Mail", "Calendar", "Code", "Chat", "Work", "Docs", "Sales"];
49
+
50
+ const hostOf = (url) => { try { return new URL(url).hostname.replace(/^www\./, "").toLowerCase(); } catch { return ""; } };
51
+ const linksFile = () => process.env.SYMBIOT_LINKS || join(CONFIG_DIR, "links.json");
52
+ // One entry from links.json, tidied, or null if it can't be linked.
53
+ function cleanEntry(e) {
54
+ if (!e || typeof e !== "object") return null;
55
+ const url = siteUrl(e.url), id = String(e.id || hostOf(url)).trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 40);
56
+ if (!url || !id) return null;
57
+ const hosts = [...new Set([hostOf(url), ...(Array.isArray(e.hosts) ? e.hosts : [])].map((h) => hostOf(siteUrl(h))).filter(Boolean))];
58
+ return { id, name: String(e.name || hostOf(url)).trim().slice(0, 60), group: String(e.group || "Company").trim().slice(0, 30), url, hosts, ...(e.watch ? { watch: true } : {}), company: true };
59
+ }
60
+ // The buttons everyone gets: the built-ins, then the company file's (an entry
61
+ // with a built-in's id replaces it; hide drops ids). A broken file is ignored,
62
+ // and said so.
63
+ function catalog() {
64
+ let file = null, error = "";
65
+ try { file = JSON.parse(readFileSync(linksFile(), "utf8")); } catch (e) { if (e && e.code !== "ENOENT") error = `${linksFile()} isn't valid JSON, so it was skipped.`; }
66
+ const extra = ((file && Array.isArray(file.links) && file.links) || []).map(cleanEntry).filter(Boolean);
67
+ const hide = new Set(((file && Array.isArray(file.hide) && file.hide) || []).map((x) => String(x).toLowerCase()));
68
+ const byId = new Map(); for (const e of [...CATALOG, ...extra]) byId.set(e.id, e);
69
+ const list = [...byId.values()].filter((e) => !hide.has(e.id));
70
+ return { list, error, file: file ? linksFile() : "" };
71
+ }
72
+
73
+ function linked() { const l = loadConfig().linked; return l && typeof l === "object" && !Array.isArray(l) ? l : {}; }
74
+ function saveLinked(l) { const cfg = loadConfig(); if (Object.keys(l).length) cfg.linked = l; else delete cfg.linked; return saveConfig(cfg); }
75
+
76
+ // Where a link stands, from its watch. state: "off" (not linked), "signin" (linked,
77
+ // not read signed in yet: sign in in the window, then close it), "ok" (read
78
+ // signed in), "signedout" or "error".
79
+ function stateOf(e, l, watches) {
80
+ if (!l) return { state: "off" };
81
+ const w = l.watch && watches.find((x) => x.id === l.watch);
82
+ if (!e.watch) return { state: l.ok ? "ok" : "signin", ...(l.checked ? { checked: l.checked } : {}) };
83
+ if (!w) return { state: "error", note: "Its watch was removed. Link it again." };
84
+ // signed out only once it has been read signed in: a read before you've
85
+ // finished signing in in the window is still "sign in"
86
+ if (w.error && /^Signed out/.test(w.error)) return w.checked ? { state: "signedout", note: "Signed out: click it to sign in again." } : { state: "signin" };
87
+ if (w.checked) return { state: "ok", checked: w.checked, ...(w.error ? { note: w.error } : {}) };
88
+ return { state: "signin", ...(w.error && !/in use|busy|already running/i.test(w.error) ? { note: w.error } : {}) };
89
+ }
90
+ function linksState() {
91
+ const c = catalog(), l = linked(), watches = watchState().watches;
92
+ const items = c.list.map((e) => ({ id: e.id, name: e.name, group: e.group, url: e.url, ...(e.company ? { company: true } : {}), ...stateOf(e, l[e.id], watches) }));
93
+ const groups = [...new Set([...GROUPS, ...items.map((x) => x.group)])].filter((g) => items.some((x) => x.group === g));
94
+ return { items, groups, linked: items.filter((x) => x.state !== "off").length, ...(c.file ? { file: c.file } : {}), ...(c.error ? { error: c.error } : {}) };
95
+ }
96
+
97
+ // Link one: trust its hosts, watch its page, open it to sign in. Clicking a link
98
+ // that's already there just opens it again to sign in (signed out, or a second
99
+ // account). Gives { ok, url, item } or { error }.
100
+ async function linkSite(id, { open = signIn } = {}) {
101
+ const e = catalog().list.find((x) => x.id === id);
102
+ if (!e) return { error: `No link called ${id}.` };
103
+ const l = linked(), had = l[id], before = new Set(trustedSites()), added = [];
104
+ for (const h of e.hosts) if (!before.has(h)) { const r = trustSite(h); if (r && r.ok) added.push(h); }
105
+ let watch = had && had.watch;
106
+ if (e.watch && !(watch && watchState().watches.some((x) => x.id === watch))) {
107
+ const w = addWatch({ site: e.url, name: e.name, every: 15 });
108
+ if (w.error) { for (const h of added) untrustSite(h); return { error: w.error }; }
109
+ watch = w.id;
110
+ }
111
+ l[id] = { at: (had && had.at) || Date.now(), ...(watch ? { watch } : {}), hosts: [...new Set([...((had && had.hosts) || []), ...added])] };
112
+ saveLinked(l);
113
+ const r = await open(e.url);
114
+ return r.error ? { error: r.error, item: linksState().items.find((x) => x.id === id) } : { ok: true, url: r.url, item: linksState().items.find((x) => x.id === id) };
115
+ }
116
+ // Read it now: its watch, or for a site that isn't watched, its page, to see
117
+ // that it's signed in. Gives the item as it stands after.
118
+ async function checkLink(id, { check = checkWatch, read = readPage } = {}) {
119
+ const e = catalog().list.find((x) => x.id === id), l = linked();
120
+ if (!e || !l[id]) return { error: `${e ? e.name : id} isn't linked.` };
121
+ if (l[id].watch) { const r = await check(l[id].watch); if (r && r.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) }; }
122
+ else {
123
+ const p = await read(e.url);
124
+ if (p && p.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) };
125
+ const l2 = linked(); if (!l2[id]) return { error: `${e.name} isn't linked.` };
126
+ l2[id] = { ...l2[id], ok: !!(p && !p.error && !p.login), checked: Date.now() }; saveLinked(l2);
127
+ }
128
+ return { ok: true, item: linksState().items.find((x) => x.id === id) };
129
+ }
130
+ // Unlink: stop watching it, and untrust the hosts this link trusted (never ones
131
+ // you trusted yourself). A host another link still uses (Outlook and Outlook
132
+ // Calendar share one) passes to that link instead, so it goes when the last
133
+ // link using it does. You stay signed in to the site in Symbiot's browser until
134
+ // you sign out there.
135
+ function unlinkSite(id) {
136
+ const l = linked(), had = l[id]; if (!had) return { error: `${id} isn't linked.` };
137
+ delete l[id];
138
+ if (had.watch) removeWatch(had.watch);
139
+ const list = catalog().list;
140
+ for (const h of had.hosts || []) {
141
+ const heir = Object.keys(l).find((k) => { const e = list.find((x) => x.id === k); return e && e.hosts.includes(h); });
142
+ if (heir) l[heir] = { ...l[heir], hosts: [...new Set([...(l[heir].hosts || []), h])] };
143
+ else untrustSite(h);
144
+ }
145
+ saveLinked(l);
146
+ return { ok: true, ...linksState() };
147
+ }
148
+
149
+ export { CATALOG, catalog, cleanEntry, linksState, linkSite, checkLink, unlinkSite };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.41.7",
3
+ "version": "0.42.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": {
@@ -21,6 +21,7 @@
21
21
  "screens.mjs",
22
22
  "headless.mjs",
23
23
  "watch.mjs",
24
+ "links.mjs",
24
25
  "phone.mjs",
25
26
  "desktop.mjs",
26
27
  "README.md",
@@ -57,7 +58,7 @@
57
58
  "@anthropic-ai/sdk": "^0.128.0"
58
59
  },
59
60
  "scripts": {
60
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
61
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
61
62
  "lint": "eslint ."
62
63
  },
63
64
  "devDependencies": {
package/screens.mjs CHANGED
@@ -112,6 +112,28 @@ function stitchPng(pieces, h) {
112
112
  for (const { img, y } of imgs) { const to = Math.min(h, y + img.h); if (to > y) img.px.copy(px, y * first.stride, 0, (to - y) * img.stride); }
113
113
  return pngCrop({ ...first, h, px }, { x: 0, y: 0, w: first.w, h });
114
114
  }
115
+ // One PNG of a window whose list scrolls inside it (Gmail's mail), as if the list
116
+ // were opened out `more` pixels longer: the first shot down to the list's bottom,
117
+ // the list's box ({ x, y, w, h } in the window) filled from each shot ({ png, y }:
118
+ // the window with the list scrolled y down) at its place, the later one's kept
119
+ // where they overlap, and what's below the list in the last shot moved down by
120
+ // `more`. Beside the opened-out list, each column carries on in its colour at the
121
+ // list's bottom edge. null when a shot can't be read, or they differ.
122
+ function stitchListPng(pieces, box, more) {
123
+ const imgs = pieces.map((p) => ({ img: pngDecode(p.png), y: Math.max(0, Math.round(p.y) || 0) })), first = imgs.length && imgs[0].img;
124
+ if (!first || !box || imgs.some(({ img }) => !img || img.w !== first.w || img.h !== first.h || img.bpp !== first.bpp || img.ctype !== first.ctype || img.depth !== first.depth)) return null;
125
+ more = Math.max(0, Math.round(more) || 0);
126
+ const H = first.h, h = H + more, st = first.stride, bpp = first.bpp, end = Math.min(H, box.y + box.h), last = imgs[imgs.length - 1].img;
127
+ if (box.x < 0 || box.y < 0 || box.w <= 0 || box.x + box.w > first.w || end <= box.y) return null;
128
+ const px = Buffer.alloc(st * h);
129
+ first.px.copy(px, 0, 0, end * st);
130
+ for (let y = end; y < end + more; y++) first.px.copy(px, y * st, (end - 1) * st, end * st);
131
+ last.px.copy(px, (end + more) * st, end * st, H * st);
132
+ for (const { img, y: at } of imgs) {
133
+ for (let r = box.y; r < end && r + at < end + more; r++) img.px.copy(px, (r + at) * st + box.x * bpp, r * st + box.x * bpp, r * st + (box.x + box.w) * bpp);
134
+ }
135
+ return pngCrop({ ...first, h, px }, { x: 0, y: 0, w: first.w, h });
136
+ }
115
137
 
116
138
  // Displays: where each one sits on the desktop, as { name, x, y, w, h } in the
117
139
  // units the click tool uses (logical pixels on Wayland, X pixels on X11, physical
@@ -378,14 +400,15 @@ function setRegions(id, regions) {
378
400
  }
379
401
  // A web page mapped in the hidden browser (headless.mjs): its screenshot, with a
380
402
  // region for each button, link and field found on it. `page` is { url, title,
381
- // scroll?, full? }: scroll is how far down the window is ({ y, max } in pixels, and
382
- // the selector of what scrolls when it isn't the page itself), when there's more;
383
- // full is set on a map of the whole page in one tall screenshot.
403
+ // scroll?, full?, list? }: scroll is how far down the window is ({ y, max } in
404
+ // pixels, and the selector of what scrolls when it isn't the page itself), when
405
+ // there's more; full is set on a map of the whole page in one tall screenshot, and
406
+ // list on one whose list (its selector) was opened out in it.
384
407
  function addPageScreen(name, png, page, regions) {
385
408
  const size = pngSize(png); if (!size) return { error: "The page's screenshot isn't a PNG." };
386
409
  const id = newId();
387
410
  try { mkdirSync(SCREENS_DIR, { recursive: true }); writePrivate(screenFile(id), png); } catch (e) { return { error: String((e && e.message) || e) }; }
388
- const p = { url: String(page.url || "").slice(0, 2000), title: String(page.title || "").trim().slice(0, 200), ...(page.full ? { full: true } : {}) };
411
+ const p = { url: String(page.url || "").slice(0, 2000), title: String(page.title || "").trim().slice(0, 200), ...(page.full ? { full: true } : {}), ...(page.full && typeof page.list === "string" && page.list.trim() ? { list: page.list.trim().slice(0, 1000) } : {}) };
389
412
  const sc = page.scroll || {}, max = Math.round(Number(sc.max) || 0);
390
413
  if (max > 0) p.scroll = { y: Math.max(0, Math.min(max, Math.round(Number(sc.y) || 0))), max, ...(typeof sc.selector === "string" && sc.selector.trim() ? { selector: sc.selector.trim().slice(0, 1000) } : {}) };
391
414
  return addScreen(id, name, size, "headless", { page: p, regions: cleanRegions(size, regions) });
@@ -463,4 +486,4 @@ function clickRegion(id, regionId) {
463
486
  return { error: `No click tool found. Install ${want}.` };
464
487
  }
465
488
 
466
- export { loadScreens, screenImage, pngSize, pngDecode, splitPng, stitchPng, captureCmds, monitorCmds, parseCosmicRandr, parseWlrRandr, parseKscreen, parseXrandr, parseLines, tidyMonitors, monitorAreas, listMonitors, portalAppId, allowScreenshots, captureScreen, splitScreen, importScreen, setRegions, addPageScreen, renameScreen, removeScreen, blueprint, center, clickCmds, clickRegion };
489
+ export { loadScreens, screenImage, pngSize, pngDecode, splitPng, stitchPng, stitchListPng, captureCmds, monitorCmds, parseCosmicRandr, parseWlrRandr, parseKscreen, parseXrandr, parseLines, tidyMonitors, monitorAreas, listMonitors, portalAppId, allowScreenshots, captureScreen, splitScreen, importScreen, setRegions, addPageScreen, renameScreen, removeScreen, blueprint, center, clickCmds, clickRegion };
package/server.mjs CHANGED
@@ -10,7 +10,7 @@ import { createServer } from "node:http";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { EMBEDDED_UI } from "./ui.mjs";
12
12
  import { VERSION, LATEST_VERSION, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
13
- import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, agentsList } from "./agents.mjs";
13
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, agentsList, startWaiting } from "./agents.mjs";
14
14
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
15
15
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
16
16
  import { computeDrift } from "./drift.mjs";
@@ -19,7 +19,8 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
19
19
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
20
20
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
21
21
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
22
- import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, watchBoard } from "./watch.mjs";
22
+ import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
23
+ import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
23
24
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
24
25
 
25
26
  // The in-app update installs the EXACT newest version (not the `latest` tag, which
@@ -210,6 +211,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
210
211
  if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
211
212
  if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
212
213
  if (u.pathname === "/api/screens/signin" && req.method === "POST") { const b = await readBody(req); return json(res, await signIn(b.site)); }
214
+ // Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
215
+ if (u.pathname === "/api/links") return json(res, linksState());
216
+ if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
217
+ if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
218
+ if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
213
219
  // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
214
220
  if (u.pathname === "/api/watch") return json(res, watchState());
215
221
  if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
@@ -222,6 +228,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
222
228
  if (u.pathname === "/api/watch/brief" && req.method === "POST") { const b = await readBody(req); return json(res, setBrief(b.on === true)); }
223
229
  // Draft a reply: a new email handed to your agent, which leaves a reply in Drafts (never sends)
224
230
  if (u.pathname === "/api/watch/draft" && req.method === "POST") { const b = await readBody(req); return json(res, draftReply(String(b.id || ""))); }
231
+ // Open in WhatsApp: a drafted chat reply, in Symbiot's browser window, to read and send
232
+ if (u.pathname === "/api/watch/open-chat" && req.method === "POST") { const b = await readBody(req); return json(res, await openChat(String(b.id || ""))); }
233
+ // a Dashboard card's chat: go over what's new with your AI before a reply is drafted
234
+ if (u.pathname === "/api/watch/chat" && req.method === "POST") { const b = await readBody(req); return json(res, await boardChat(String(b.id || ""), b.question)); }
235
+ if (u.pathname === "/api/watch/chat/clear" && req.method === "POST") { const b = await readBody(req); return json(res, clearBoardChat(String(b.id || ""))); }
225
236
  // Watch on your phone (phone.mjs). On the computer: listen on your network
226
237
  // for the phone (only on your click), a pairing code, the phones paired.
227
238
  // On the phone: pair with the computer, ask it now, forget it.
@@ -281,6 +292,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
281
292
  startWeekly(writeup); // the weekly write-up + notification, when switched on in Settings
282
293
  startWatches(); // pages you watch (Screens → Watch), read every few minutes
283
294
  startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
295
+ setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
284
296
  }
285
297
 
286
298
  export { updateCmd, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
package/tasks.mjs CHANGED
@@ -438,6 +438,7 @@ function buildTasksMd(name, ctx, list) {
438
438
  L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
439
439
  `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
440
440
  "- Start every option with who acts, because the user reads it as something they'd be agreeing to do: `👤 You:` when they have to do something (a setting, a click, a command), `🤖 Agent:` when picking it is enough and the next run does the work. If it takes both, put their step first: `👤 You: allow npm install in Settings. 🤖 Agent: the next run adds ESLint`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`, `keep it as it is`) gets no tag.",
441
+ "- A `👤 You:` answer holds the next run back until the user's step is done. If their step changes a file, name the file in backticks (`.env`, `~/.termux/termux.properties`): the next run starts once it changes.",
441
442
  "- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: …\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
442
443
  "- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
443
444
  "- An idea for a different project than this one (Symbiot itself, say, the app that sent you this brief) starts with that project's folder name, `- [repo: symbiot] …`, so it goes to that project's tasks instead of this one's.",
package/ui.mjs CHANGED
@@ -95,6 +95,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
95
95
  .drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
96
96
  .dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
97
97
  .dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
98
+ .lgroup{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:8px 0 0}.lgroup .fl{min-width:64px;margin-left:0}
99
+ .lnk{display:inline-flex;align-items:center;gap:4px}.lnk .fchip{display:inline-flex;align-items:center;gap:6px}
100
+ .lnk.ok .fchip{border-color:#2a6b52}.lnk.signin .fchip,.lnk.signedout .fchip,.lnk.error .fchip{border-color:var(--amber)}
101
+ .lnk .lst{font-size:11px;color:var(--faint)}.lnk .lmore,.lnk .lrm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:14px;padding:0 2px}.lnk .lrm:hover{color:var(--amber)}
98
102
  .agent{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
99
103
  .adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
100
104
  .adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
@@ -140,7 +144,8 @@ label.check input{width:auto}
140
144
  .bcard .bc{font-size:26px;font-weight:700;color:var(--faint);margin-top:6px}.bcard.has .bc{color:var(--green)}
141
145
  .bcard .bl{font-size:12px;color:var(--faint)}
142
146
  .bcard .bi{display:flex;gap:8px;align-items:flex-start;padding:7px 0;border-top:1px solid var(--line);font-size:13px;color:var(--text)}
143
- .bcard .bi .t{flex:1;min-width:0;overflow-wrap:anywhere}.bcard .bi button{padding:4px 9px;font-size:12px}
147
+ .bcard .bi{flex-wrap:wrap}.bcard .bi .t{flex:1 1 150px;min-width:0;overflow-wrap:anywhere}.bcard .bi button{padding:4px 9px;font-size:12px}
148
+ .bcard .tchat{border-top:1px solid var(--line);border-radius:9px;margin-top:8px}
144
149
  .bcard .bbrief{white-space:pre-line;margin:8px 0;padding:8px 10px;border-left:3px solid var(--green);background:var(--ink3);border-radius:0 8px 8px 0;font-size:13px;color:var(--bone)}
145
150
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
146
151
  @media(max-width:600px){#panel-tasks .row>#newtask,#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
@@ -181,7 +186,7 @@ label.check input{width:auto}
181
186
  <div id="review" class="review hidden"></div>
182
187
  <div style="margin-top:22px;border-top:1px solid var(--line);padding-top:4px">
183
188
  <div class="tgroup">Screens <span class="tcount">experimental &middot; blueprints for screen automation</span></div>
184
- <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. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Or let Symbiot do it all for a web page: <b>Map page</b> opens it in a hidden browser, takes its screenshot and marks every button, link and field by itself, and <b>Press</b> on one of them follows it there and maps the next page; <b>Type</b> on a field types into it there. A map only has what fits in the window, so on a longer page <b>Scroll down</b> maps the next part, or <b>Whole page</b> maps all of it in one tall screenshot (a page that scrolls as a whole, not a list inside it like Gmail's). Only a mapped page is typed into: nothing types on your real screen. <b>Watch</b> on a mapped page (your inbox, say) has Symbiot read it again every few minutes while it runs and tell you what's new there.</div>
189
+ <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. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Or let Symbiot do it all for a web page: <b>Map page</b> opens it in a hidden browser, takes its screenshot and marks every button, link and field by itself, and <b>Press</b> on one of them follows it there and maps the next page; <b>Type</b> on a field types into it there. A map only has what fits in the window, so on a longer page <b>Scroll down</b> maps the next part, or <b>Whole page</b> maps all of it in one tall screenshot (where a list scrolls inside the page, like Gmail's mail, that list opened out: a whole inbox on one screen). Only a mapped page is typed into: nothing types on your real screen. <b>Watch</b> on a mapped page (your inbox, say) has Symbiot read it again every few minutes while it runs and tell you what's new there.</div>
185
190
  <div class="row" style="margin-top:10px"><input id="pagesite" placeholder="a web page to map by itself: gmail, github.com/pulls or a web address" style="flex:1"><button class="act" id="mappage">Map page</button><button class="ghost" id="pagesignin" title="open this site in Symbiot's own browser as a window, to sign in once; close it when you're done">Sign in</button></div>
186
191
  <div class="row" style="margin-top:10px"><input id="screenname" placeholder="name the screen, e.g. GitHub PR page" style="flex:1"><select id="screenwhich" class="hidden" title="which display to capture" style="flex:0 0 auto;width:auto"></select><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>
187
192
  <div id="screenmsg"></div>
@@ -193,6 +198,7 @@ label.check input{width:auto}
193
198
  <section id="panel-board" class="hidden">
194
199
  <div class="row"><span class="muted" id="boardsum" style="flex:1">Everything you watch, side by side.</span><select id="boardhours" title="how far back" style="flex:0 0 auto;width:auto"><option value="24">last 24 hours</option><option value="72">last 3 days</option><option value="168">last 7 days</option></select><button class="ghost" id="boardrefresh">Refresh</button></div>
195
200
  <div id="boardmsg"></div>
201
+ <div id="boardlinks"></div>
196
202
  <div id="board" class="board"></div>
197
203
  </section>
198
204
  <section id="panel-run" class="hidden">
@@ -264,6 +270,13 @@ label.check input{width:auto}
264
270
  </div>
265
271
  </div>
266
272
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
273
+ <label>Link your work</label>
274
+ <div class="note muted" style="margin-top:2px">One click per site you work in: it opens the site in Symbiot's own browser, where you sign in the usual way (its own login, SSO and 2FA; Symbiot never sees your password). Symbiot then trusts the site and watches its inbox or notifications, so what arrives there shows on the Dashboard and in Week and Standup. Close the window when you're signed in.</div>
275
+ <div id="links"></div>
276
+ <div id="linksmsg"></div>
277
+ <div class="note muted" id="linksnote">For a whole team, one <b>links.json</b> in Symbiot's config folder adds your company's own sites and hides the ones you don't use, so everyone gets the same buttons.</div>
278
+ </div>
279
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
267
280
  <label>Trusted sites for Screens <span class="muted">(experimental)</span></label>
268
281
  <div id="trustedsites"></div>
269
282
  <div class="row" style="margin-top:6px"><input id="newtrusted" placeholder="a site, e.g. mail.google.com or github.com" style="flex:1"><button class="ghost" id="addtrusted">Trust site</button></div>
@@ -311,7 +324,7 @@ function tabs(){return document.querySelectorAll('.tab');}
311
324
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
312
325
  var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isBoard=tab==='board',isRun=(tab==='week'||tab==='standup'||tab==='todo');
313
326
  $('panel-map').classList.toggle('hidden',!isMap);
314
- $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard)loadBoard();
327
+ $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard){loadBoard();loadLinks();}
315
328
  $('panel-run').classList.toggle('hidden',!isRun);
316
329
  $('panel-settings').classList.toggle('hidden',!isSet);
317
330
  $('panel-tasks').classList.toggle('hidden',!isTasks);
@@ -532,11 +545,16 @@ if(!ans.length){msg.innerHTML="<div class='note err'>Pick an option or type an a
532
545
  box.querySelectorAll('button').forEach(function(b){b.disabled=true;});
533
546
  api('/api/agents/answer',{path:a.path,answers:ans,rerun:rerun}).then(function(r){
534
547
  if(!r||r.error){box.querySelectorAll('button').forEach(function(b){b.disabled=false;});msg.innerHTML="<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";return;}
535
- msg.innerHTML="<div class='note ok'>&#10003; Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" &middot; your agent is picking them up now.":".")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
548
+ msg.innerHTML="<div class='note ok'>&#10003; Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" &middot; your agent is picking them up now.":".")+(r.yours&&r.yours.length?"<div class='err'>&#128100; Still yours to do: "+esc(r.yours.join(' '))+"</div>":"")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
536
549
  var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
537
550
  if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
538
551
  box.querySelectorAll('.qidea').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
539
552
  api('/api/tasks/add',{text:s.text,repo:s.repo||a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});});}
553
+ // A step of yours an answer picked (👤 You): the next run waits for it (agents.mjs waitingFor)
554
+ function waitHtml(a){var w=a.waiting;if(!w)return '';var fs=(w.files||[]).map(function(f){return "<code>"+esc(f)+"</code>";}).join(" or ");
555
+ return "<div class='aq'><h4>&#9208; Waiting on your step</h4><div class='qt'>&#128100; "+esc(w.step)+"</div><div class='qc'>"+(fs?"Your agent "+(w.rerun?"starts by itself":"can start")+" once "+fs+" changes"+(w.rerun?", while Symbiot runs":"")+". Done it some other way?":"Your agent waits for it, so it doesn't stop on the same questions again.")+" Start it now once it's done.</div><div class='row'><button class='act wstart' data-id='"+esc(a.id)+"'>Start it now</button></div></div>";}
556
+ function wireWaits(el){el.querySelectorAll('.wstart').forEach(function(btn){btn.addEventListener('click',function(){var a=agentById(btn.getAttribute('data-id'));if(!a)return;btn.disabled=true;
557
+ api('/api/open',{path:a.path,force:true}).then(function(x){var msg=document.getElementById('agentsmsg');msg.innerHTML=x&&x.opened?"<div class='note ok'>&#10003; Started your agent in <b>"+esc(a.name)+"</b>.</div>":"<div class='note err'>"+esc(a.name)+": "+(x&&x.busy?"an agent is already running there.":"it didn&#39;t start. Check the command in Settings.")+"</div>";loadAgents();}).catch(function(e){btn.disabled=false;document.getElementById('agentsmsg').innerHTML="<div class='note err'>"+esc(String((e&&e.message)||e))+"</div>";});});});}
540
558
  function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
541
559
  function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
542
560
  if(!list||!list.length){AGENTLIST=[];el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
@@ -546,6 +564,8 @@ saveDrafts(el);AGENTLIST=list;
546
564
  el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
547
565
  var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
548
566
  if(a.fromHeld)st+=" &middot; started on the tasks held for the last run";
567
+ if(a.earlier)st+=" &middot; "+(a.status==='running'?"started outside this window":"ran before Symbiot last started");
568
+ if(a.waiting)st+=" &middot; <span style='color:var(--amber)'>waiting on your step</span>";
549
569
  if(nQs(a))st+=" &middot; <span style='color:var(--amber)'>needs your answers</span>";
550
570
  if(a.held!=null){var hn=a.held.length,ht=a.held.map(function(t){return "&bull; "+esc(t).replace(/'/g,'&#39;');}).join('&#10;');
551
571
  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.");
@@ -562,11 +582,11 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
562
582
  }else if(a.status==='done'){
563
583
  b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
564
584
  }
565
- b+=askHtml(a);
585
+ b+=waitHtml(a)+askHtml(a);
566
586
  b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
567
587
  return "<div class='agent'>"+b+"</div>";}).join("");
568
588
  el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
569
- restoreDrafts(el);wireAsks(el);
589
+ restoreDrafts(el);wireAsks(el);wireWaits(el);
570
590
  stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
571
591
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
572
592
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
@@ -651,6 +671,33 @@ box.querySelectorAll('.punpair').forEach(function(b){b.addEventListener('click',
651
671
  var pp=$('ppair');if(pp)pp.addEventListener('click',function(){var a=$('paddr').value,cd=$('pcode').value;pp.disabled=true;pp.textContent='Pairing...';api('/api/phone/pair',{address:a,code:cd}).then(function(x){renderPhoneLink(x,x&&x.error?{text:x.error}:{ok:true,text:'Paired. What Watch finds on '+x.name+' shows up here as a notification.'});if(x&&x.error&&$('paddr')){$('paddr').value=a;$('pcode').value=cd;}});});
652
672
  var pc=$('pcheck');if(pc)pc.addEventListener('click',function(){pc.disabled=true;api('/api/phone/check',{}).then(function(x){renderPhoneLink(x,x&&!x.error?{ok:true,text:x.shown?'Showed '+x.shown+' notification(s).':'Asked: nothing new.'}:null);});});
653
673
  var pf=$('pforget');if(pf)pf.addEventListener('click',function(){if(typeof confirm==='function'&&!confirm('Forget your computer? Its notifications stop.'))return;api('/api/phone/forget',{}).then(function(x){renderPhoneLink(x);});});}
674
+ // Link your work (links.mjs): one button per standard site. A click opens it in
675
+ // Symbiot's browser to sign in, trusts it and watches it. Its dot: grey not
676
+ // linked, amber waiting for you to sign in (or signed out), green linked. Shown
677
+ // in Settings, and on the Dashboard until something is linked.
678
+ var LINKS=null;
679
+ var LINKWORD={ok:'linked',signin:'sign in, then close the window',signedout:'signed out',error:'needs a fix'};
680
+ function escQ(t){return esc(t).replace(/'/g,'&#39;');}
681
+ function linksHtml(d,board){var h=board?"<div class='note muted'>Link your work to see what arrives there here, and in Week and Standup:</div>":"";
682
+ if(d.error)h+="<div class='note err'>"+esc(d.error)+"</div>";
683
+ d.groups.forEach(function(g){h+="<div class='lgroup'><span class='fl'>"+esc(g)+"</span>";
684
+ d.items.filter(function(x){return x.group===g;}).forEach(function(it){var on=it.state!=='off';
685
+ h+="<span class='lnk "+escQ(it.state)+"' data-id='"+escQ(it.id)+"'><button class='fchip lbtn' title='"+escQ(it.note||(on?'open '+it.url+' to sign in again':'sign in at '+it.url+' and link it'))+"'><span class='dot-c' style='background:"+(it.state==='ok'?'var(--green)':on?'var(--amber)':'var(--line)')+"'></span>"+esc(it.name)+"</button>"+
686
+ (on?"<span class='lst'>"+esc(LINKWORD[it.state]||'')+"</span><button class='lmore' title='check it now'>&#8635;</button><button class='lrm' title='unlink: stop watching it and stop trusting it'>&times;</button>":"")+"</span>";});
687
+ h+="</div>";});return h;}
688
+ function linksOut(html){var o=document.getElementById('linksmsg');if(o)o.innerHTML=html;var b=document.getElementById('boardmsg');if(b&&LINKS&&!LINKS.linked)b.innerHTML=html;}
689
+ function renderLinks(){if(!LINKS)return;['links','boardlinks'].forEach(function(id){var el=document.getElementById(id);if(!el)return;
690
+ if(id==='boardlinks'&&LINKS.linked){el.innerHTML='';return;}
691
+ el.innerHTML=linksHtml(LINKS,id==='boardlinks');
692
+ el.querySelectorAll('.lnk').forEach(function(sp){var lid=sp.getAttribute('data-id'),name=sp.querySelector('.lbtn').textContent;
693
+ sp.querySelector('.lbtn').addEventListener('click',function(){linksOut("<div class='note muted'>Opening "+esc(name)+"&hellip;</div>");
694
+ api('/api/links/link',{id:lid}).then(function(r){if(!r||r.error){linksOut("<div class='note err'>"+esc(name)+": "+esc((r&&r.error)||'failed')+"</div>");loadLinks();return;}
695
+ linksOut("<div class='note ok'>Opened "+esc(name)+" in Symbiot's browser. Sign in there as you normally do, then close that window: Symbiot checks it within a minute.</div>");loadLinks();
696
+ [20000,60000,120000].forEach(function(ms){setTimeout(loadLinks,ms);});});});
697
+ var m=sp.querySelector('.lmore');if(m)m.addEventListener('click',function(){m.disabled=true;api('/api/links/check',{id:lid}).then(function(r){m.disabled=false;
698
+ if(r&&r.busy)linksOut("<div class='note muted'>Symbiot's browser is busy (a sign-in window is open?). Close it, then check again.</div>");else if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");loadLinks();loadBoard();});});
699
+ var x=sp.querySelector('.lrm');if(x)x.addEventListener('click',function(){api('/api/links/unlink',{id:lid}).then(function(){linksOut('');loadLinks();loadTrusted();loadBoard();});});});});}
700
+ function loadLinks(){api('/api/links').then(function(d){if(d&&d.items){LINKS=d;renderLinks();}});}
654
701
  // Trusted sites: where Screens' Press and Type don't ask first (headless.mjs).
655
702
  function loadTrusted(){api('/api/screens/trusted').then(function(d){var box=document.getElementById('trustedsites');var sites=(d&&d.sites)||[];
656
703
  box.innerHTML=sites.length?sites.map(function(h){return "<div class='task' data-h='"+esc(h)+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(h)+"</span><button class='rm rmtrusted' title='stop trusting it: press and type ask first again'>&times;</button></div>";}).join(""):"<div class='muted' style='font-size:12px'>None yet: Press and Type ask first on every site.</div>";
@@ -724,7 +771,7 @@ function scrBox(r,s,cls){return "<div class='scrbox"+(cls?" "+cls:"")+"' style='
724
771
  function renderScreen(){var v=$('screenview'),s=SCREEN;if(!s){v.innerHTML='';return;}
725
772
  var h="<div class='row' style='margin-top:10px'><input id='scrname' title='rename this screen' style='flex:1'><span class='muted'>"+(s.monitor?esc(s.monitor.name)+(s.monitor.where?" ("+esc(s.monitor.where)+")":"")+" &middot; ":"")+s.w+" &times; "+s.h+" px</span>"+(s.page?"<button class='ghost' id='scrremap' title='open this page in the hidden browser again and map it as it is now'>Map again</button>"+(watchOf(s)?"<button class='ghost' id='scrwatch' title='Symbiot reads this page every few minutes and tells you what&#39;s new: click to stop'>Watching &#10003;</button>":"<button class='ghost' id='scrwatch' title='read this page again every few minutes while Symbiot runs, and tell me what&#39;s new on it'>Watch</button>"):"")+(!s.monitor&&!s.page&&MONITORS.length>1?"<button class='ghost' id='scrsplit' title='cut this screenshot into one screen per display, regions included (this one stays)'>Split by display</button>":"")+"<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>";
726
773
  // a page taller than its window: where this screen is on it, and Scroll to map the rest
727
- var sc=s.page&&s.page.scroll,up=sc&&sc.y>0,down=sc&&sc.y<sc.max,whole=sc&&!sc.selector; // whole: the page itself scrolls (not a list inside it, like Gmail's)
774
+ var sc=s.page&&s.page.scroll,up=sc&&sc.y>0,down=sc&&sc.y<sc.max,whole=!!sc; // whole: the page, or a list inside it (Gmail's) opened out
728
775
  if(s.page)h+="<div class='row' style='margin-top:6px'><span class='muted' style='font-size:12px;flex:1;overflow-wrap:anywhere'>Mapped in the hidden browser: "+esc(s.page.url)+(sc?" &middot; <b>"+(up&&down?"more above and below":down?"more below":"the end of the page")+"</b>: a map only has what fits in the window, so scroll to map the rest"+(whole?", or map the whole page at once":""):s.page.full?" &middot; <b>the whole page</b>, in one tall screenshot":"")+"</span>"+(up?"<button class='ghost scrscroll' data-to='up' title='scroll up in the hidden browser and map what&#39;s there as a new screen'>&uarr; Scroll up</button>":"")+(down?"<button class='ghost scrscroll' data-to='down' title='scroll down in the hidden browser and map what&#39;s there as a new screen'>Scroll down &darr;</button>":"")+(whole?"<button class='ghost scrwhole' title='map all of this page in one tall screenshot, every button, link and field on it, as a new screen'>Whole page &varr;</button>":"")+"</div>";
729
776
  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>";
730
777
  h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region &middot; coordinates are screenshot pixels</div>";
@@ -783,7 +830,7 @@ if(!gh)h+="<div class='row' style='margin-top:8px'><span class='muted' style='fo
783
830
  if(ws.length){h+="<label class='check' style='margin-top:8px'><input type='checkbox' id='wbrief'"+(WATCH.brief?" checked":"")+"> <span>Brief me: the AI connected in Settings says what needs me and what can wait, here and in the notification. <span class='muted'>It's sent what's new: for mail, the sender, subject and preview. With a local Ollama model nothing leaves this computer.</span></span></label>";
784
831
  var b0=bs[0]&&ns.some(function(n){return n.ts===bs[0].ts&&n.watch===bs[0].watch;})?bs[0]:null;
785
832
  if(b0)h+="<div class='note' style='white-space:pre-line;margin-top:6px'><b>Brief</b> <span class='muted' style='font-size:12px'>"+esc(b0.name)+" &middot; "+b0.count+" new &middot; "+agoTxt(b0.ts)+"</span><br>"+esc(b0.text)+"</div>";
786
- h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New &middot; "+ns.length+"</span><button class='ghost' id='wclear' title='clear this list (Symbiot still remembers what it has seen)'>Clear</button></div>"+ns.slice(0,20).map(function(n,i){return "<div class='task' data-i='"+i+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:12px'>"+esc(n.name)+" &middot; "+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost wopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost wdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent opens it in your inbox through Screens, writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
833
+ h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New &middot; "+ns.length+"</span><button class='ghost' id='wclear' title='clear this list (Symbiot still remembers what it has seen)'>Clear</button></div>"+ns.slice(0,20).map(function(n,i){return "<div class='task' data-i='"+i+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:12px'>"+esc(n.name)+" &middot; "+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost wopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost wdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent opens it in your inbox through Screens, writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+(n.chat&&n.drafted?"<button class='ghost wopenwa' title='"+OPEN_WA_TIP+"'>Open in WhatsApp</button>":"")+"</div>";}).join('')
787
834
  :"<div class='muted' style='font-size:12px;margin-top:6px'>Nothing new yet. New rows show up here, and as a notification. Standup counts what's waiting on you, and your agent reads them with <b>symbiot watch new</b>.</div>";}
788
835
  box.innerHTML=h;
789
836
  var wg=$('wgithub');if(wg)wg.addEventListener('click',function(){wg.disabled=true;api('/api/watch/add',{site:'github',every:5}).then(function(x){wg.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
@@ -799,16 +846,26 @@ box.querySelectorAll('.wopen').forEach(function(b){b.addEventListener('click',fu
799
846
  box.querySelectorAll('.wdraft').forEach(function(b){b.addEventListener('click',function(){var n=ns[+b.closest('.task').getAttribute('data-i')];b.disabled=true;
800
847
  api('/api/watch/draft',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
801
848
  $('screenmsg').innerHTML="<div class='note ok'>"+(x.chat?DRAFTING_CHAT:DRAFTING)+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});});});
849
+ box.querySelectorAll('.wopenwa').forEach(function(b){b.addEventListener('click',function(){openChatUI(ns[+b.closest('.task').getAttribute('data-i')],b,function(cls,html){$('screenmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";});});});
802
850
  var wc=$('wclear');if(wc)wc.addEventListener('click',function(){api('/api/watch/clear',{}).then(loadWatchUI);});}
803
851
  // what Check now found, for Watching and the Dashboard
804
852
  function watchCheckMsg(w,x){return x.busy?"The hidden browser is busy with a map, press or type. It reads "+esc(w.name)+" once that's done.":x.learned!=null?"Read "+esc(x.name)+": learned the "+x.learned+" things listed there now. From here on, anything new is noted.":x["new"]&&x["new"].length?"&#10003; "+x["new"].length+" new on "+esc(x.name)+".":"Read "+esc(x.name)+": nothing new.";}
805
853
  // a chat (WhatsApp): the reply is typed into the chat's message box in Symbiot's browser, unsent
806
854
  var DRAFT_CHAT_TIP="your coding agent opens the chat in Symbiot&#39;s hidden browser and types a reply into its message box, unsent. It never presses Send or Enter";
807
- var DRAFTING_CHAT="&#10003; Your agent is drafting a reply in that chat. It opens WhatsApp in Symbiot's hidden browser, types the reply into the chat's message box and leaves it there unsent: it never presses Send or Enter. Once it's done (the Agents tab), type web.whatsapp.com under Screens and click <b>Sign in</b>: the chat shows the reply in its box, for you to read and send.";
855
+ var DRAFTING_CHAT="&#10003; Your agent is drafting a reply in that chat. It opens WhatsApp in Symbiot's hidden browser, types the reply into the chat's message box and leaves it there unsent: it never presses Send or Enter. Once it's done (the Agents tab), click <b>Open in WhatsApp</b> on the message: the chat shows the reply in its box, for you to read and send.";
856
+ // Open in WhatsApp (watch.mjs openChat): Symbiot's browser, as a window, at web.whatsapp.com
857
+ var OPEN_WA_TIP="open WhatsApp in Symbiot&#39;s browser, where the chat shows your agent&#39;s reply in its message box, for you to read and send";
858
+ function openChatUI(n,b,say){if(!n)return;b.disabled=true;
859
+ api('/api/watch/open-chat',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){say('err',esc((x&&x.error)||'failed'));return;}
860
+ say('ok',"&#10003; Opened WhatsApp in Symbiot's browser, in a window of its own. The chat shows your agent's reply in its message box: read it, change it if you like, and send it there. Close the window when you're done, so Watch can read WhatsApp again.");}).catch(function(e){b.disabled=false;say('err',esc(String((e&&e.message)||e)));});}
808
861
  var DRAFTING="&#10003; Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.";
809
862
  // ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
810
863
  var BOARD=null;var BOARDICON={mail:'&#9993;&#65039;',github:'&#128276;',chat:'&#128172;',page:'&#127760;'};
811
- function loadBoard(){api('/api/watch/board?hours='+(+$('boardhours').value||24)).then(function(b){if(b&&b.cards){BOARD=b;renderBoard();}});}
864
+ // Talk it over (watch.mjs boardChat): a chat on a card, open by its id, with what's half typed kept across a refresh
865
+ var BTALK={},BTALKDRAFT={},BTALKBUSY=0;
866
+ var TALKHINT="<div class='muted' style='font-size:12px'>Go over what's new here with your AI: what needs you, and what to say to whom. Then click <b>Draft a reply</b> on one, and your agent writes it the way you agreed here. Your AI is sent what this card lists (for mail: the sender, subject and preview).</div>";
867
+ function talking(){var f=document.activeElement;return BTALKBUSY>0||!!(f&&f.closest&&f.closest('.btalk'));}
868
+ function loadBoard(){api('/api/watch/board?hours='+(+$('boardhours').value||24)).then(function(b){if(b&&b.cards){BOARD=b;if(!talking())renderBoard();}});}
812
869
  function boardMsg(cls,html){$('boardmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";}
813
870
  function renderBoard(){var b=BOARD,cs=b.cards,el=$('board');
814
871
  var tab=$('boardtab');if(tab)tab.textContent='Dashboard'+(b.total?' · '+b.total:'');
@@ -818,14 +875,29 @@ $('boardgo').addEventListener('click',function(){setTab('map');var w=$('watchbox
818
875
  var when=b.hours===24?'since yesterday':b.hours===72?'in the last 3 days':'in the last 7 days';
819
876
  var waiting=cs.filter(function(c){return c.count;}).map(function(c){return c.label+(c.source==='page'?' on '+c.name:'');});
820
877
  $('boardsum').innerHTML=waiting.length?"<b style='color:var(--bone)'>Waiting on you "+when+":</b> "+esc(waiting.join(', ')):"Nothing new "+when+" on the "+cs.length+" thing"+(cs.length===1?"":"s")+" you watch.";
821
- el.innerHTML=cs.map(function(c,i){
822
- return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'><span>"+(BOARDICON[c.source]||BOARDICON.page)+"</span><span class='bn' title='"+esc(c.url)+"'>"+esc(c.name)+"</span>"+(c.count?"<button class='ghost bseen' title='set this card back to 0: only what comes in after counts. It stays under Watching, and the other cards keep theirs' style='padding:4px 9px;font-size:12px'>Seen</button>":"")+"<button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
878
+ el.innerHTML=cs.map(function(c,i){var nt=Math.ceil(((c.chat||[]).length)/2);
879
+ return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'><span>"+(BOARDICON[c.source]||BOARDICON.page)+"</span><span class='bn' title='"+esc(c.url)+"'>"+esc(c.name)+"</span>"+(c.count?"<button class='ghost bseen' title='set this card back to 0: only what comes in after counts. It stays under Watching, and the other cards keep theirs' style='padding:4px 9px;font-size:12px'>Seen</button>":"")+"<button class='ghost btalkbtn' title='talk what&#39;s new here over with your AI before you draft a reply' style='padding:4px 9px;font-size:12px'>&#128172;"+(nt?" "+nt:"")+"</button><button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
823
880
  +"<div class='bc'>"+c.count+"</div><div class='bl'>"+esc(c.count?c.label.replace(/^\\d+ /,''):'nothing new')+" &middot; "+(c.checked?"read "+agoTxt(c.checked):c.last?"tried "+agoTxt(c.last):"first read within a minute")+(c.via==='gh'?" &middot; through gh":"")+"</div>"
824
881
  +(c.error?"<div class='note err'>"+esc(c.error)+"</div>":"")
825
882
  +(c.brief?"<div class='bbrief'>"+esc(c.brief.text)+"</div>":"")
826
- +c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost bdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
827
- +(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>&hellip;and "+(c.count-c.items.length)+" more</div>":"")+"</div>";}).join('');
883
+ +c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost bdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+(n.chat&&n.drafted?"<button class='ghost bopenwa' title='"+OPEN_WA_TIP+"'>Open in WhatsApp</button>":"")+"</div>";}).join('')
884
+ +(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>&hellip;and "+(c.count-c.items.length)+" more</div>":"")
885
+ +(BTALK[c.id]?"<div class='tchat btalk'><div class='msgs'>"+(nt?chatMsgs(c.chat):TALKHINT)+"</div><div class='row'><input class='talkq' placeholder='Ask about these, or say how to answer one...' style='flex:1'><button class='ghost talksend'>Ask</button><a class='talkclear' title='forget this conversation'>clear</a></div></div>":"")+"</div>";}).join('');
828
886
  function card(btn){return cs[+btn.closest('.bcard').getAttribute('data-i')];}
887
+ el.querySelectorAll('.btalkbtn').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);BTALK[c.id]=!BTALK[c.id];renderBoard();var q=BTALK[c.id]&&el.querySelector(".bcard[data-i='"+cs.indexOf(c)+"'] .talkq");if(q&&q.focus)q.focus();});});
888
+ el.querySelectorAll('.btalk').forEach(function(box){var c=card(box),inp=box.querySelector('.talkq'),send=box.querySelector('.talksend'),msgs=box.querySelector('.msgs');
889
+ inp.value=BTALKDRAFT[c.id]||'';inp.addEventListener('input',function(){BTALKDRAFT[c.id]=inp.value;});
890
+ function ask(){var q=(inp.value||'').trim();if(!q||send.disabled)return;send.disabled=true;inp.value='';BTALKDRAFT[c.id]='';BTALKBUSY++;
891
+ if(!(c.chat&&c.chat.length))msgs.innerHTML='';
892
+ msgs.insertAdjacentHTML('beforeend',chatMsgs([{role:'user',text:q}])+"<div class='msg a muted thinking'>Thinking&hellip;</div>");
893
+ function done(){BTALKBUSY=Math.max(0,BTALKBUSY-1);send.disabled=false;var th=msgs.querySelector('.thinking');if(th)th.remove();}
894
+ api('/api/watch/chat',{id:c.id,question:q}).then(function(r){done();
895
+ if(r.error==='not-connected'){msgs.insertAdjacentHTML('beforeend',"<div class='msg a'>Connect a model in Settings to talk it over - Ollama is free and runs locally.</div>");return;}
896
+ if(r.error){msgs.insertAdjacentHTML('beforeend',"<div class='msg a err'>"+esc(r.error)+"</div>");return;}
897
+ c.chat=r.chat||[];msgs.innerHTML=chatMsgs(c.chat);var tb=box.closest('.bcard').querySelector('.btalkbtn');if(tb)tb.innerHTML='&#128172; '+Math.ceil(c.chat.length/2);}).catch(function(e){done();msgs.insertAdjacentHTML('beforeend',"<div class='msg a err'>"+esc(String((e&&e.message)||e))+"</div>");});}
898
+ send.addEventListener('click',ask);inp.addEventListener('keydown',function(e){if(e.key==='Enter')ask();});
899
+ box.querySelector('.talkclear').addEventListener('click',function(){api('/api/watch/chat/clear',{id:c.id}).then(function(){c.chat=[];msgs.innerHTML=TALKHINT;var tb=box.closest('.bcard').querySelector('.btalkbtn');if(tb)tb.innerHTML='&#128172;';});});});
900
+ el.querySelectorAll('.bopenwa').forEach(function(btn){btn.addEventListener('click',function(){openChatUI(item(btn),btn,boardMsg);});});
829
901
  function item(btn){return card(btn).items[+btn.closest('.bi').getAttribute('data-j')];}
830
902
  el.querySelectorAll('.bcheck').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);btn.disabled=true;btn.textContent='Reading…';
831
903
  api('/api/watch/check',{id:c.id}).then(function(x){if(!x||x.error)boardMsg('err',esc((x&&x.error)||'failed'));else boardMsg(x["new"]&&x["new"].length?'ok':'muted',watchCheckMsg(c,x));loadWatchUI();}).catch(function(e){btn.disabled=false;btn.textContent='Check now';boardMsg('err',esc(String((e&&e.message)||e)));});});});
@@ -982,5 +1054,5 @@ function appBar(p){var b=$('appbar');if(!b||!p.termux||window.SymbiotAndroid||ty
982
1054
  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',{});}
983
1055
  setInterval(heartbeat,4000);heartbeat(true);
984
1056
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
985
- initGraphEvents();syncP();refresh();loadMap();firstTab();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
1057
+ initGraphEvents();syncP();refresh();loadMap();firstTab();loadAgentCfg();loadScanRoots();loadPhone();loadLinks();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
986
1058
  </script></body></html>`;