symbiot 0.41.4 → 0.41.6

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/index.mjs CHANGED
@@ -38,8 +38,8 @@ import { gitDefaultBranch, loadDeploys, driftRepo, computeDrift } from "./drift.
38
38
  import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks } from "./tasks.mjs";
39
39
  import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
40
40
  import { loadScreens, screenImage, blueprint } from "./screens.mjs";
41
- import { mapPage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
42
- import { watchState, addWatch, removeWatch, newsSince, checkWatch, setBrief, isMail, draftReply } from "./watch.mjs";
41
+ import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
42
+ import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard } from "./watch.mjs";
43
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
44
44
  import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
45
45
 
@@ -81,9 +81,11 @@ function cmdPush() {
81
81
  if (has("open")) {
82
82
  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})."));
83
83
  else {
84
- const busy = r.written.map((w) => ({ w, e: runHandoff(w.path) })).filter((x) => x.e && x.e.busy);
85
- const n = r.written.length - busy.length;
84
+ const runs = r.written.map((w) => ({ w, e: runHandoff(w.path, { force: has("force") }) }));
85
+ const busy = runs.filter((x) => x.e && x.e.busy), blocked = runs.filter((x) => x.e && x.e.blocked);
86
+ const n = r.written.length - busy.length - blocked.length;
86
87
  if (n) console.log("\n" + c.g("→ ") + `Handed ${n} repo(s) to your agent (${r.handoff}).`);
88
+ for (const { w, e } of blocked) console.log("\n" + c.y(`${w.name}: `) + e.note + c.d(" (--force starts it anyway)"));
87
89
  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."));
88
90
  }
89
91
  } else {
@@ -327,7 +329,11 @@ async function cmdScreens() {
327
329
  for (const s of list) console.log(`${s.id} ${s.name} ${c.d(`${(s.regions || []).length} regions · ${s.page ? s.page.url : s.w + "×" + s.h + " " + s.via}`)}`);
328
330
  return;
329
331
  }
330
- if (sub === "map") { const body = { site: a1, name: flag("name", "") }; return out(screenJson((await viaApp("/api/screens/map", body)) || await mapPage(body.site, body.name))); }
332
+ if (sub === "map") { const body = { site: a1, name: flag("name", ""), whole: has("whole") }; return out(screenJson((await viaApp("/api/screens/map", body)) || await mapPage(body.site, body.name, { whole: body.whole }))); }
333
+ if (sub === "whole") {
334
+ const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
335
+ return out(screenJson((await viaApp("/api/screens/whole", { id: s.id })) || await wholePage(s.id)));
336
+ }
331
337
  if (sub === "scroll") {
332
338
  const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
333
339
  const body = { id: s.id, to: a2 || "down" };
@@ -340,17 +346,19 @@ async function cmdScreens() {
340
346
  const want = String(a2 || "").toLowerCase(), rs = s.regions || [];
341
347
  const r = rs.find((x) => x.id === a2) || rs.find((x) => x.label.toLowerCase() === want) || (rs.filter((x) => x.label.toLowerCase().includes(want)).length === 1 && rs.find((x) => x.label.toLowerCase().includes(want)));
342
348
  if (!want || !r) return out({ error: `No region "${a2 || ""}" on that screen (give its id, or a label that matches one region).` });
343
- // a draft reply's agent (SYMBIOT_DRAFT, watch.mjs) never presses Send
344
- const body = { id: s.id, region: r.id, confirmed: has("yes"), ...(sub === "type" ? { text: a3, enter: has("enter") } : { noSend: !!process.env.SYMBIOT_DRAFT }) };
345
- const done = (await viaApp("/api/screens/" + sub, body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend }) : await typeRegion(s.id, r.id, a3, { enter: body.enter, confirmed: body.confirmed }));
349
+ // a draft reply's agent (SYMBIOT_DRAFT, watch.mjs) never presses Send, or Enter (it sends in a chat)
350
+ const body = { id: s.id, region: r.id, confirmed: has("yes"), noSend: !!process.env.SYMBIOT_DRAFT, ...(sub === "type" ? { text: a3, enter: has("enter") } : {}) };
351
+ const done = (await viaApp("/api/screens/" + sub, body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend }) : await typeRegion(s.id, r.id, a3, { enter: body.enter, confirmed: body.confirmed, noSend: body.noSend }));
346
352
  // not a trusted site: say how to go ahead (only you can trust a site, in the app's Settings)
347
353
  if (done && done.confirm) return out({ error: `${done.error} Add --yes to go ahead, or list ${done.host} under Trusted sites in Symbiot's Settings.` });
348
354
  return out(screenJson(done));
349
355
  }
350
356
  console.log(`${c.b("symbiot screens")} ${c.d("— experimental")}
351
357
  symbiot screens list your screens
352
- symbiot screens map <site> [--name N] open a site in a hidden browser and map
353
- its buttons, links and fields (JSON)
358
+ symbiot screens map <site> [--name N] [--whole]
359
+ open a site in a hidden browser and map
360
+ its buttons, links and fields (JSON);
361
+ --whole: all of the page in one tall screen
354
362
  symbiot screens show <id> a screen's blueprint (JSON)
355
363
  symbiot screens press <id> <region> [--yes] press a region there, map where it lands
356
364
  symbiot screens type <id> <field> "text" [--enter] [--yes]
@@ -358,6 +366,9 @@ async function cmdScreens() {
358
366
  symbiot screens scroll <id> [down|up|top|bottom]
359
367
  scroll the page, map what's in the window then
360
368
  (a map's "more" says there's more below or above)
369
+ symbiot screens whole <id> map all of that page in one tall screenshot
370
+ (a page that scrolls as a whole, not a list
371
+ inside it like Gmail's: scroll that)
361
372
  symbiot screens signin <site> sign in once, in Symbiot's browser window
362
373
  --yes is needed unless the page's site is under Trusted sites in the app's Settings.
363
374
  While the app runs, these use its hidden browser, which stays open a few minutes:
@@ -367,7 +378,7 @@ async function cmdScreens() {
367
378
  }
368
379
 
369
380
  // ---- `symbiot watch`: pages Symbiot keeps track of, and what's new on them ----
370
- // new, add, remove and check print JSON, for you or an agent.
381
+ // new, board, seen, add, remove and check print JSON, for you or an agent.
371
382
  async function cmdWatch() {
372
383
  const [sub = "list", a1] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !["--every", "--hours"].includes(all[i - 1]));
373
384
  const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
@@ -381,12 +392,16 @@ async function cmdWatch() {
381
392
  for (const n of news.slice(0, 20)) console.log(` ${c.d(new Date(n.ts).toLocaleString())} ${n.text.slice(0, 110)} ${c.d(n.name.slice(0, 30))}`);
382
393
  return;
383
394
  }
384
- if (sub === "new") { const mail = new Set(watchState().watches.filter((w) => isMail(w.url)).map((w) => w.id)); return out(newsSince(hours).map((n) => (mail.has(n.watch) ? { ...n, mail: true } : n))); }
395
+ // the Dashboard's cards, for an agent or a status bar (.total is the count on the Dashboard tab)
396
+ if (sub === "board") return out(watchBoard(Math.min(168, Math.max(1, hours))));
397
+ if (sub === "seen") return out(a1 ? seenWatch(a1) : { error: "Give the watch's id: symbiot watch board lists them." });
398
+ if (sub === "new") return out(markNews(newsSince(hours), watchState().watches));
385
399
  // the same as the app's Draft a reply button: through the app when it runs, so its Agents tab tracks the run
386
400
  if (sub === "draft") {
387
- if (!a1) return out({ error: "Give the email's id: symbiot watch new lists them, and the ones marked \"mail\": true can get a reply." });
401
+ if (!a1) return out({ error: "Give the email's id: symbiot watch new lists them, and the ones marked \"mail\": true (or \"chat\": true) can get a reply." });
388
402
  const r = (await viaApp("/api/watch/draft", { id: a1 })) || draftReply(a1);
389
- return out(r.ok ? { ...r, next: "Your agent is writing the reply and leaves it in Drafts, never sent. Its log is in " + join(r.dir, ".symbiot", "agent.log") + " (and the app's Agents tab)." } : r);
403
+ const where = r.chat ? "types it into the chat's message box in Symbiot's browser, never sent" : "leaves it in Drafts, never sent";
404
+ return out(r.ok ? { ...r, next: `Your agent is writing the reply and ${where}. Its log is in ` + join(r.dir, ".symbiot", "agent.log") + " (and the app's Agents tab)." } : r);
390
405
  }
391
406
  if (sub === "add") { const screen = loadScreens().some((s) => s.id === a1) ? a1 : ""; return out(addWatch({ screen, site: screen ? "" : a1, every: flag("every", 15) })); }
392
407
  if (sub === "remove") return out(removeWatch(String(a1 || "")));
@@ -404,10 +419,17 @@ async function cmdWatch() {
404
419
  symbiot watch add github your GitHub notifications: review requests,
405
420
  failed CI runs (through gh when it's signed in)
406
421
  symbiot watch new [--hours 24] what's new, newest first (JSON)
422
+ symbiot watch board [--hours 24] the Dashboard's cards: how many are new on
423
+ each page, and the newest few (JSON, for an
424
+ agent or a status bar: .total is the count)
425
+ symbiot watch seen <id> set a card's count back to 0, like its Seen
426
+ button (what it found stays in watch new)
407
427
  symbiot watch check [id] read them now (JSON)
408
428
  symbiot watch draft <id> your agent drafts a reply to that new email
409
429
  ("mail": true in watch new) and leaves it in
410
- Drafts, never sent, like Draft a reply in the app
430
+ Drafts, never sent, like Draft a reply in the app;
431
+ to a WhatsApp chat ("chat": true), it types it
432
+ into the chat's message box, unsent
411
433
  symbiot watch brief [on|off] your AI says what needs you, and what can wait
412
434
  symbiot watch remove <id> stop watching it
413
435
  While the app runs it reads each page every few minutes in its hidden browser
@@ -502,7 +524,7 @@ ${c.b("Usage")}
502
524
  symbiot todo what's still on your plate
503
525
  symbiot app open the visual app in your browser
504
526
  symbiot drift [--fetch] what's out of sync / at risk across repos
505
- symbiot push [--open] write tasks into each repo (and run your agent)
527
+ symbiot push [--open [--force]] write tasks into each repo (and run your agent)
506
528
  symbiot login connect it to an AI (once)
507
529
  symbiot whoami show how it's connected
508
530
  symbiot logout forget saved credentials
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.41.4",
3
+ "version": "0.41.6",
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": {
package/screens.mjs CHANGED
@@ -100,6 +100,19 @@ function splitPng(buf, rects) {
100
100
  return rects.map((r) => pngCrop(img, r));
101
101
  }
102
102
 
103
+ // One PNG h pixels tall from pieces of it ({ png, y }: a window's screenshot and
104
+ // how far down the page it was taken, for a whole-page map). Where two overlap,
105
+ // the later piece's rows are kept: what stuck to the bottom of the window (a bar,
106
+ // a cookie banner) is hidden by then. null when a piece can't be read, or they
107
+ // differ in width or format.
108
+ function stitchPng(pieces, h) {
109
+ const imgs = pieces.map((p) => ({ img: pngDecode(p.png), y: Math.max(0, Math.round(p.y) || 0) })), first = imgs.length && imgs[0].img;
110
+ if (!first || !(h > 0) || imgs.some(({ img }) => !img || img.w !== first.w || img.bpp !== first.bpp || img.ctype !== first.ctype || img.depth !== first.depth)) return null;
111
+ const px = Buffer.alloc(first.stride * h);
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
+ return pngCrop({ ...first, h, px }, { x: 0, y: 0, w: first.w, h });
114
+ }
115
+
103
116
  // Displays: where each one sits on the desktop, as { name, x, y, w, h } in the
104
117
  // units the click tool uses (logical pixels on Wayland, X pixels on X11, physical
105
118
  // pixels from the virtual screen's corner on Windows, points on macOS). Each
@@ -365,13 +378,14 @@ function setRegions(id, regions) {
365
378
  }
366
379
  // A web page mapped in the hidden browser (headless.mjs): its screenshot, with a
367
380
  // region for each button, link and field found on it. `page` is { url, title,
368
- // scroll? }: scroll is how far down the window is ({ y, max } in pixels, and the
369
- // selector of what scrolls when it isn't the page itself), when there's more.
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.
370
384
  function addPageScreen(name, png, page, regions) {
371
385
  const size = pngSize(png); if (!size) return { error: "The page's screenshot isn't a PNG." };
372
386
  const id = newId();
373
387
  try { mkdirSync(SCREENS_DIR, { recursive: true }); writePrivate(screenFile(id), png); } catch (e) { return { error: String((e && e.message) || e) }; }
374
- const p = { url: String(page.url || "").slice(0, 2000), title: String(page.title || "").trim().slice(0, 200) };
388
+ const p = { url: String(page.url || "").slice(0, 2000), title: String(page.title || "").trim().slice(0, 200), ...(page.full ? { full: true } : {}) };
375
389
  const sc = page.scroll || {}, max = Math.round(Number(sc.max) || 0);
376
390
  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) } : {}) };
377
391
  return addScreen(id, name, size, "headless", { page: p, regions: cleanRegions(size, regions) });
@@ -449,4 +463,4 @@ function clickRegion(id, regionId) {
449
463
  return { error: `No click tool found. Install ${want}.` };
450
464
  }
451
465
 
452
- export { loadScreens, screenImage, pngSize, pngDecode, splitPng, captureCmds, monitorCmds, parseCosmicRandr, parseWlrRandr, parseKscreen, parseXrandr, parseLines, tidyMonitors, monitorAreas, listMonitors, portalAppId, allowScreenshots, captureScreen, splitScreen, importScreen, setRegions, addPageScreen, renameScreen, removeScreen, blueprint, center, clickCmds, clickRegion };
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 };
package/server.mjs CHANGED
@@ -10,16 +10,16 @@ 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, answerQuestions, agentsList } from "./agents.mjs";
13
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, agentsList } 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";
17
17
  import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
18
18
  import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce } from "./writeups.mjs";
19
19
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
20
- import { mapPage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
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, checkWatch, startWatches, setBrief, draftReply, watchBoard } from "./watch.mjs";
22
+ import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, watchBoard } from "./watch.mjs";
23
23
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
24
24
 
25
25
  // The in-app update installs the EXACT newest version (not the `latest` tag, which
@@ -161,10 +161,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
161
161
  if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: scanHome() });
162
162
  if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
163
163
  if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
164
- if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
164
+ if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors, connectors: connectorsInfo() }); }
165
165
  if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
166
166
  if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
167
- 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 : "" }); }
167
+ if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || ""), { force: !!b.force }); return json(res, { opened: !!e && !e.busy && !e.blocked, busy: !!(e && e.busy), auto: !!(e && e.auto), blocked: !!(e && e.blocked), note: (e && e.note) || "", id: (e && e.id) || "" }); }
168
168
  if (u.pathname === "/api/setup-local" && req.method === "POST") {
169
169
  const b = await readBody(req);
170
170
  if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
@@ -199,12 +199,13 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
199
199
  // A web page in the hidden browser (headless.mjs): mapped by itself, no
200
200
  // screen needed. Press acts on the real site, signed in as you, so it's
201
201
  // confirmed like a click; Sign in opens a window the user signs in with.
202
- if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name))); }
202
+ if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name, { whole: b.whole === true }))); }
203
203
  // press / type: refused unless confirmed, or the page's site is one you trust
204
204
  if (u.pathname === "/api/screens/press" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await pressRegion(String(b.id || ""), String(b.region || ""), { confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
205
- // scroll: only looks, so it never asks
205
+ // scroll and whole page: they only look, so they never ask
206
+ if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || "")))); }
206
207
  if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down")))); }
207
- if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true }))); }
208
+ if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
208
209
  if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
209
210
  if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
210
211
  if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
@@ -217,6 +218,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
217
218
  if (u.pathname === "/api/watch/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeWatch(String(b.id || ""))); }
218
219
  if (u.pathname === "/api/watch/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkWatch(String(b.id || ""))); }
219
220
  if (u.pathname === "/api/watch/clear" && req.method === "POST") return json(res, clearNews());
221
+ if (u.pathname === "/api/watch/seen" && req.method === "POST") { const b = await readBody(req); return json(res, seenWatch(String(b.id || ""))); }
220
222
  if (u.pathname === "/api/watch/brief" && req.method === "POST") { const b = await readBody(req); return json(res, setBrief(b.on === true)); }
221
223
  // Draft a reply: a new email handed to your agent, which leaves a reply in Drafts (never sends)
222
224
  if (u.pathname === "/api/watch/draft" && req.method === "POST") { const b = await readBody(req); return json(res, draftReply(String(b.id || ""))); }
@@ -235,7 +237,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
235
237
  if (u.pathname === "/api/desktop/weekly" && req.method === "POST") { const b = await readBody(req); return json(res, setWeekly(b)); }
236
238
  if (u.pathname === "/api/desktop/weekly/run" && req.method === "POST") { const b = await readBody(req); return json(res, await runWeekly(writeup, { notify: b.notify !== false })); }
237
239
  if (u.pathname === "/api/desktop/autostart" && req.method === "POST") { const b = await readBody(req); return json(res, setAutostart(!!b.on, bin)); }
238
- 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 writeup(cmd)); }
240
+ // the Week tab's week is saved to weeks/ too, as Write it now saves it (no notification: you're looking at it)
241
+ 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, cmd === "week" ? await runWeekly(writeup, { notify: false }) : await writeup(cmd)); }
239
242
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
240
243
  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), ...(IN_TERMUX ? { termux: true } : {}) }); }
241
244
  if (u.pathname === "/api/update" && req.method === "POST") {
package/tasks.mjs CHANGED
@@ -30,13 +30,42 @@ function addTask(text, repo) {
30
30
  }
31
31
  function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
32
32
  function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
33
- function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; saveTasks(t); } return it || { error: "not found" }; }
33
+ function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; if (it.removedBy) { it.kept = true; delete it.removedBy; delete it.merged; } saveTasks(t); } return it || { error: "not found" }; }
34
34
  // Which task texts the agent checked off in a repo's .symbiot/TASKS.md
35
35
  function completedInRepo(repoPath) {
36
36
  try { return readFileSync(join(repoPath, ".symbiot", "TASKS.md"), "utf8").split("\n").filter((l) => /^\s*-\s*\[x\]/i.test(l)).map((l) => l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase()); }
37
37
  catch { return []; }
38
38
  }
39
39
 
40
+ // A task that removes others: `Drop the "gosolr's own WhatsApp number" task`,
41
+ // "Merge the two `WA_WABA_ID` tasks into one". An agent can't edit your task
42
+ // list, so approving one was all that happened: the tasks it named stayed open,
43
+ // and every regenerated TASKS.md brought them back. Once it's approved, they go
44
+ // here: dropped ones are archived, merged ones too, all but the newest. It
45
+ // names them with a quoted phrase (`…`, "…", “…”) whose words are all in
46
+ // theirs ('s aside), and only reaches open tasks in its repo from before its
47
+ // approval. A phrase that names more than MAX_REMOVED is too vague to act on.
48
+ // Archived, each keeps removedBy (and merged); one you restore stays (kept).
49
+ const REMOVES = /^\s*(drop|remove|delete|merge|combine|dedupe|deduplicate)\b/i, MAX_REMOVED = 4;
50
+ const noPossessive = (s) => String(s || "").replace(/['’]s\b/gi, "");
51
+ function removalOf(text) {
52
+ const s = String(text || ""), m = s.match(REMOVES), at = s.search(/\btasks?\b/i); if (!m || at < 0) return null;
53
+ // up to "task": "…the two `WA_WABA_ID` tasks. Both are the same edit to `.env`" names no `.env` task
54
+ const phrases = [...s.slice(0, at).matchAll(/`([^`]+)`|"([^"]+)"|“([^”]+)”/g)].map((x) => taskWords(noPossessive(x[1] || x[2] || x[3]))).filter(Boolean);
55
+ return phrases.length ? { merge: /^(merge|combine|dedupe)/i.test(m[1]), phrases } : null;
56
+ }
57
+ function applyRemovals(t) {
58
+ let n = 0; const now = Date.now();
59
+ for (const r of t.filter((x) => x.approvedAt && x.repo)) {
60
+ const rm = removalOf(r.text); if (!rm) continue;
61
+ const named = (x) => { const w = new Set(taskWords(noPossessive(x.text)).split(" ")); return rm.phrases.some((p) => p.split(" ").every((y) => w.has(y))); };
62
+ const hit = t.filter((x) => x !== r && x.repo === r.repo && !x.done && !x.archived && !x.review && !x.kept && (x.ts || 0) < r.approvedAt && named(x));
63
+ if (hit.length > MAX_REMOVED || (rm.merge && hit.length < 2)) continue;
64
+ const go = rm.merge ? hit.sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(1) : hit;
65
+ for (const x of go) { Object.assign(x, { done: true, archived: true, archivedAt: now, removedBy: r.id }); if (rm.merge) x.merged = true; n++; }
66
+ }
67
+ return n;
68
+ }
40
69
  // "Check what was handed out, see what's completed, then archive it" — with an
41
70
  // approval step in between. An agent ticking an item in TASKS.md means "done,
42
71
  // please review", NOT archived: it waits in review until the user approves it
@@ -52,8 +81,9 @@ function syncTasks() {
52
81
  if (checkedByRepo[x.repo].some((c) => sameTask(c, x.text))) { x.review = true; x.reviewAt = Date.now(); review++; }
53
82
  }
54
83
  for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
84
+ const removed = applyRemovals(t);
55
85
  saveTasks(t);
56
- return { review, archived, started };
86
+ return { review, archived, started, removed };
57
87
  }
58
88
  // git with an argv (task text goes into commit messages — never through a shell)
59
89
  function git(repo, args, timeout = 30000) {
@@ -352,8 +382,9 @@ function approveRepo(repo, opts = {}) {
352
382
  if (r.error) return r;
353
383
  const now = Date.now();
354
384
  for (const x of items) { x.review = false; x.done = true; x.archived = true; x.archivedAt = now; x.approvedAt = now; for (const k of ["branch", "commit", "pr"]) if (r[k]) x[k] = r[k]; }
385
+ const removed = applyRemovals(t);
355
386
  saveTasks(t);
356
- return { ...r, approved: items.length };
387
+ return { ...r, approved: items.length, ...(removed ? { removed } : {}) };
357
388
  }
358
389
  // "Approve changes without a task": ship the uncommitted changes even though no
359
390
  // ticked task is behind them (a fix the agent made but didn't tick). If tasks are
@@ -415,7 +446,8 @@ function buildTasksMd(name, ctx, list) {
415
446
  return L.join("\n") + "\n";
416
447
  }
417
448
  function pushTasks(filter) {
418
- let tasks = loadTasks().filter((t) => !t.done && !t.archived && !t.review);
449
+ const all = loadTasks(); if (applyRemovals(all)) saveTasks(all); // what an approved Drop/Merge named never goes out again
450
+ let tasks = all.filter((t) => !t.done && !t.archived && !t.review);
419
451
  if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
420
452
  if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
421
453
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
@@ -445,4 +477,4 @@ function pushTasks(filter) {
445
477
  return { empty: false, written, unresolved, handoff: handoffCmd() };
446
478
  }
447
479
 
448
- export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
480
+ export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
package/ui.mjs CHANGED
@@ -181,7 +181,7 @@ label.check input{width:auto}
181
181
  <div id="review" class="review hidden"></div>
182
182
  <div style="margin-top:22px;border-top:1px solid var(--line);padding-top:4px">
183
183
  <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. 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>
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>
185
185
  <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
186
  <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
187
  <div id="screenmsg"></div>
@@ -255,6 +255,7 @@ label.check input{width:auto}
255
255
  <input id="agentcmd" type="text" placeholder="e.g. aider --message &quot;{prompt}&quot; --yes · code {dir} · leave blank to just write the file">
256
256
  <div class="note muted">Runs in each repo after tasks are written. Use <b>{dir}</b> = repo path, <b>{prompt}</b> = the task instruction. Works with any agent or editor &mdash; it's your command.</div>
257
257
  <div id="agentpresets" style="margin-top:8px"></div>
258
+ <div class="note muted" id="agentconnectors"></div>
258
259
  <div id="grantbox" style="margin-top:10px">
259
260
  <label style="font-size:12px;color:var(--faint)">When an agent asks to run something or read a folder, grant it here instead of editing the command:</label>
260
261
  <div class="row" style="margin-top:6px"><input id="granttool" placeholder="allow a command, e.g. python3 or pytest" style="flex:1"><button class="ghost" id="granttoolbtn">Allow command</button></div>
@@ -321,13 +322,18 @@ if(isMap&&!mapLoaded)loadMap();
321
322
  if(isTasks){fillTaskRepos();loadTasks();}
322
323
  if(isDrift&&!driftLoaded)loadDrift();
323
324
  if(isAgents)loadAgents(); else stopAgentsPoll();}
324
- tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
325
+ var tabPicked=false;tabs().forEach(function(t){t.addEventListener('click',function(){tabPicked=true;setTab(t.dataset.tab);});});
326
+ // Watching a page (your inbox, GitHub)? The app opens on the Dashboard instead of
327
+ // the Map, unless you've picked a tab already. The Map still loads behind it.
328
+ function firstTab(){api('/api/watch/board').then(function(b){if(b&&b.cards&&b.cards.length&&!tabPicked&&current==='map')setTab('board');}).catch(function(){});}
325
329
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
326
330
  // the status opens Settings: on a phone its tab is scrolled out of sight
327
331
  $('status').style.cursor='pointer';$('status').addEventListener('click',function(){setTab('settings');var t=document.querySelector('.tab[data-tab=settings]');if(t&&t.scrollIntoView)t.scrollIntoView({block:'nearest',inline:'nearest'});});
328
332
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
329
333
  api('/api/run',{cmd:current}).then(function(r){var f=$('outfoot');if(r.error==='not-connected'){$('out').textContent='Not connected yet - open Settings and pick an AI.';f.style.display='none';return;}
330
- $('out').textContent=r.text||'(no output)';$('out').classList.remove('muted');$('copy').classList.remove('hidden');if(r.footer){f.textContent=r.footer;f.style.display='block';}else{f.style.display='none';}});});
334
+ // a week is saved to weeks/ as well (r.file), like Settings' Write it now
335
+ var foot=[r.footer,r.file?'Saved to '+r.file:'',r.error&&r.text&&r.error!==r.text?r.error:''].filter(Boolean).join(' · ');
336
+ $('out').textContent=r.text||'(no output)';$('out').classList.remove('muted');$('copy').classList.remove('hidden');if(foot){f.textContent=foot;f.style.display='block';}else{f.style.display='none';}});});
331
337
  $('copy').addEventListener('click',function(){navigator.clipboard.writeText($('out').textContent);$('copy').textContent='Copied';setTimeout(function(){$('copy').textContent='Copy';},1400);});
332
338
  function syncP(){var p=$('provider').value;var local=p==='ollama';$('keyWrap').classList.toggle('hidden',local);$('baseWrap').classList.toggle('hidden',!local);
333
339
  $('getkey').textContent=local?'About Ollama':'Where do I get a key?';$('modelHint').textContent='(default '+DEFMODEL[p]+')';$('model').placeholder='(blank = '+DEFMODEL[p]+')';}
@@ -397,7 +403,7 @@ if(r.error==='not-connected'){el.innerHTML="<h4>AI review &middot; <span class='
397
403
  var body=r.text||'(no output)';reviewCache[path]={text:body,verdict:r.verdict||"",ideas:r.ideas||[],footer:r.footer};el.innerHTML=reviewHtml(name,body,r.verdict||"",r.ideas||[],tasks,r.footer);wireIdeas();});}
398
404
  var ALLTASKS=[];var PENDTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
399
405
  var TORDER=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
400
- function taskRow(t,arch){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'>"+(arch?"":"<input type='checkbox' class='taskchk' title='mark done (auto-archives on sync)'"+(t.done?" checked":"")+">")+"<span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+askBtn(t)+(arch?"<button class='rm restore' title='restore to active'>&#8630;</button>":"<button class='rm' title='remove'>&times;</button>")+"</div>";}
406
+ function taskRow(t,arch){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'>"+(arch?"":"<input type='checkbox' class='taskchk' title='mark done (auto-archives on sync)'"+(t.done?" checked":"")+">")+"<span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+(arch&&t.removedBy?"<span class='rp' title='An approved task "+(t.merged?"merged it into another":"dropped it")+". Restore it to keep it.'>"+(t.merged?"merged":"dropped")+"</span>":"")+askBtn(t)+(arch?"<button class='rm restore' title='restore to active'>&#8630;</button>":"<button class='rm' title='remove'>&times;</button>")+"</div>";}
401
407
  function wireTaskRows(el){el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
402
408
  var cb=row.querySelector('.taskchk');if(cb)cb.addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
403
409
  wireAsk(row,id);
@@ -583,13 +589,21 @@ if(r.empty){btn.disabled=false;o.innerHTML="<div class='muted' style='margin-top
583
589
  var n=(r.written||[]).length;var opens=[];var h="<div class='drift' style='margin-top:10px'><div class='dh'><span class='dot-c'></span><span class='dn'>Done &mdash; wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
584
590
  if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>&#10003; <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span>"+(w.held?" <span class='ev'>&#9208; held: an agent is still running there, so these land when it finishes</span>":"")+"</li>";});h+="</ul>";}
585
591
  if(r.unresolved&&r.unresolved.length){var names=r.unresolved.map(function(u){return u.name;}).join(", ");var hasNoRepo=r.unresolved.some(function(u){return u.name==='(no repo)';});h+="<div class='dd' style='margin-top:6px'>&#9888; not sent: "+esc(names)+". "+(hasNoRepo?"Pick a repo in the dropdown next to <b>Add</b> so the task has somewhere to go.":"That repo isn't in the map &mdash; add its folder in Settings.")+"</div>";}
586
- if(r.handoff&&r.written&&r.written.length){opens=r.written.map(function(w){return api('/api/open',{path:w.path}).then(function(x){if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="&#9888; <b>"+esc(w.name)+"</b> already has an agent running, so another wasn&#39;t started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
592
+ 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.blocked){var d=document.createElement('div');d.className='dd';d.innerHTML="&#9208; <b>"+esc(w.name)+"</b>: "+esc(x.note)+" <button class='ghost' style='padding:2px 8px;font-size:12px'>Start it anyway</button>";d.querySelector('button').addEventListener('click',function(){this.disabled=true;api('/api/open',{path:w.path,force:true}).then(function(y){d.innerHTML=y&&y.opened?"&#10003; Started an agent in <b>"+esc(w.name)+"</b>.":"&#9888; <b>"+esc(w.name)+"</b>: "+(y&&y.busy?"an agent is already running there.":"it didn&#39;t start. Check the command in Settings.");});});o.appendChild(d);}if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="&#9888; <b>"+esc(w.name)+"</b> already has an agent running, so another wasn&#39;t started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
587
593
  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>";}
588
594
  h+="</div>";
589
595
  o.innerHTML=h;
590
596
  // re-enable only once every handoff has started (or been refused), so a double click can't send twice
591
597
  Promise.all(opens).then(function(){btn.disabled=false;});}).catch(function(e){btn.disabled=false;o.innerHTML="<div class='err' style='margin-top:10px'>Couldn&#39;t write tasks: "+esc(String((e&&e.message)||e))+"</div>";});}
598
+ // Connectors linked to Claude (claude.ai's Drive, Gmail…) reach Claude runs only: say which, or that this command doesn't get them.
599
+ var CONNECTORS=[];
600
+ function renderConnectors(){var el=document.getElementById('agentconnectors');if(!CONNECTORS.length){el.innerHTML='';return;}
601
+ var nm=function(c){return "<b>"+esc(c.name.replace(/^claude\\.ai\\s+/i,''))+"</b>";},ready=CONNECTORS.filter(function(c){return c.ready;}),wait=CONNECTORS.filter(function(c){return !c.ready;});
602
+ var cmd=document.getElementById('agentcmd').value;if(/orca-ide/.test(cmd)&&/claude/.test(cmd)){el.innerHTML="&#128268; Claude in Orca&#39;s tab asks you before it uses your connectors ("+CONNECTORS.map(nm).join(", ")+").";return;}
603
+ if(!/^\\s*claude\\b/.test(cmd)){el.innerHTML="&#128268; Your Claude connectors ("+CONNECTORS.map(nm).join(", ")+") reach Claude Code runs only. This command isn&#39;t Claude, so its runs can&#39;t use them.";return;}
604
+ el.innerHTML=(ready.length?"&#128268; Your agent&#39;s runs can use "+ready.map(nm).join(", ")+": linked to Claude, so each run allows their tools.":"")+(wait.length?(ready.length?" ":"&#128268; ")+wait.map(nm).join(", ")+(wait.length===1?" needs":" need")+" authorizing in your claude.ai connector settings first.":"");}
592
605
  function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElementById('agentcmd').value=d.cmd||'';
606
+ CONNECTORS=(d.connectors&&d.connectors.list)||[];renderConnectors();
593
607
  var chips=[];(d.agents||[]).forEach(function(a){chips.push(a);});(d.editors||[]).forEach(function(e){chips.push(e);});
594
608
  var box=document.getElementById('agentpresets');
595
609
  if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Nothing detected on PATH &mdash; type your own command above.</span>";return;}
@@ -599,7 +613,7 @@ var h="<span class='muted' style='font-size:12px'>Detected &mdash; click to use:
599
613
  if(chips.length>na)h+="<div class='muted' style='font-size:12px;margin-top:8px'>Editors &mdash; opens only, no review:</div>"+chips.slice(na).map(function(c,i){return chip(c,na+i);}).join("");
600
614
  box.innerHTML=h;
601
615
  box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
602
- function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
616
+ function saveAgent(){renderConnectors();api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
603
617
  function grant(kind){var i=document.getElementById(kind==='tool'?'granttool':'grantdir');var v=(i.value||'').trim();if(!v)return;var body={};body[kind]=v;api('/api/agent/grant',body).then(function(r){var n=document.getElementById('grantnote');if(!r||r.error){n.className='note err';n.textContent=(r&&r.error)||'could not grant';return;}i.value='';n.className='note ok';n.textContent=(kind==='tool'?'Command allowed':'Folder allowed')+' — the agent can use it on its next run.';if(r.cmd)document.getElementById('agentcmd').value=r.cmd;});}
604
618
  function loadScanRoots(){api('/api/scanroots').then(function(d){var box=document.getElementById('scanroots');var roots=d.effective||[];
605
619
  box.innerHTML=roots.map(function(r){var custom=(d.roots||[]).indexOf(r)>=0;return "<div class='task' data-p='"+esc(r)+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(r)+"</span>"+(r===d.home?"<span class='rp'>home</span>":"")+(custom?"<button class='rm rmroot' title='remove'>&times;</button>":"")+"</div>";}).join("");
@@ -710,8 +724,8 @@ function scrBox(r,s,cls){return "<div class='scrbox"+(cls?" "+cls:"")+"' style='
710
724
  function renderScreen(){var v=$('screenview'),s=SCREEN;if(!s){v.innerHTML='';return;}
711
725
  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>";
712
726
  // a page taller than its window: where this screen is on it, and Scroll to map the rest
713
- var sc=s.page&&s.page.scroll,up=sc&&sc.y>0,down=sc&&sc.y<sc.max;
714
- 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":"")+"</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>":"")+"</div>";
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)
728
+ 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>";
715
729
  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>";
716
730
  h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region &middot; coordinates are screenshot pixels</div>";
717
731
  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>";
@@ -747,6 +761,7 @@ $('scrregions').querySelectorAll('.scrtype').forEach(function(b){b.addEventListe
747
761
  var te=$('screnter');if(te)te.addEventListener('change',function(){STYPEENTER=!!te.checked;});
748
762
  var rm=$('scrremap');if(rm)rm.addEventListener('click',function(){mapUI(s.page.url,rm);});
749
763
  document.querySelectorAll('#screenview .scrscroll').forEach(function(b){b.addEventListener('click',function(){scrollUI(s,b.getAttribute('data-to'),b);});});
764
+ document.querySelectorAll('#screenview .scrwhole').forEach(function(b){b.addEventListener('click',function(){wholeUI(s,b);});});
750
765
  var wb=$('scrwatch');if(wb)wb.addEventListener('click',function(){watchUI(s,wb);});}
751
766
  // Watch (watch.mjs): a mapped page read again every few minutes while Symbiot
752
767
  // runs, and what's new on it. Only reads: nothing there is pressed or typed.
@@ -768,7 +783,7 @@ if(!gh)h+="<div class='row' style='margin-top:8px'><span class='muted' style='fo
768
783
  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>";
769
784
  var b0=bs[0]&&ns.some(function(n){return n.ts===bs[0].ts&&n.watch===bs[0].watch;})?bs[0]:null;
770
785
  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>";
771
- 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?"<button class='ghost wdraft' title='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('')
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('')
772
787
  :"<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>";}
773
788
  box.innerHTML=h;
774
789
  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;}
@@ -783,10 +798,13 @@ box.querySelectorAll('.wopen').forEach(function(b){b.addEventListener('click',fu
783
798
  // Draft a reply (watch.mjs draftReply): handed to your agent, which never sends it
784
799
  box.querySelectorAll('.wdraft').forEach(function(b){b.addEventListener('click',function(){var n=ns[+b.closest('.task').getAttribute('data-i')];b.disabled=true;
785
800
  api('/api/watch/draft',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
786
- $('screenmsg').innerHTML="<div class='note ok'>"+DRAFTING+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});});});
801
+ $('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));});});});
787
802
  var wc=$('wclear');if(wc)wc.addEventListener('click',function(){api('/api/watch/clear',{}).then(loadWatchUI);});}
788
803
  // what Check now found, for Watching and the Dashboard
789
804
  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
+ // a chat (WhatsApp): the reply is typed into the chat's message box in Symbiot's browser, unsent
806
+ 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.";
790
808
  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.";
791
809
  // ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
792
810
  var BOARD=null;var BOARDICON={mail:'&#9993;&#65039;',github:'&#128276;',chat:'&#128172;',page:'&#127760;'};
@@ -801,19 +819,21 @@ var when=b.hours===24?'since yesterday':b.hours===72?'in the last 3 days':'in th
801
819
  var waiting=cs.filter(function(c){return c.count;}).map(function(c){return c.label+(c.source==='page'?' on '+c.name:'');});
802
820
  $('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.";
803
821
  el.innerHTML=cs.map(function(c,i){
804
- 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><button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
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>"
805
823
  +"<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>"
806
824
  +(c.error?"<div class='note err'>"+esc(c.error)+"</div>":"")
807
825
  +(c.brief?"<div class='bbrief'>"+esc(c.brief.text)+"</div>":"")
808
- +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?"<button class='ghost bdraft' title='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('')
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('')
809
827
  +(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>&hellip;and "+(c.count-c.items.length)+" more</div>":"")+"</div>";}).join('');
810
828
  function card(btn){return cs[+btn.closest('.bcard').getAttribute('data-i')];}
811
829
  function item(btn){return card(btn).items[+btn.closest('.bi').getAttribute('data-j')];}
812
830
  el.querySelectorAll('.bcheck').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);btn.disabled=true;btn.textContent='Reading…';
813
831
  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)));});});});
832
+ el.querySelectorAll('.bseen').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);btn.disabled=true;
833
+ api('/api/watch/seen',{id:c.id}).then(function(x){if(!x||x.error){btn.disabled=false;boardMsg('err',esc((x&&x.error)||'failed'));return;}$('boardmsg').innerHTML='';loadBoard();}).catch(function(e){btn.disabled=false;boardMsg('err',esc(String((e&&e.message)||e)));});});});
814
834
  el.querySelectorAll('.bopen').forEach(function(btn){btn.addEventListener('click',function(){var n=item(btn);if(n&&/^https?:/.test(n.href))window.open(n.href,'_blank','noopener');});});
815
835
  el.querySelectorAll('.bdraft').forEach(function(btn){btn.addEventListener('click',function(){var n=item(btn);btn.disabled=true;
816
- api('/api/watch/draft',{id:n.id}).then(function(x){btn.disabled=false;if(!x||x.error){boardMsg('err',esc((x&&x.error)||'failed'));return;}boardMsg('ok',DRAFTING);loadWatchUI();}).catch(function(e){btn.disabled=false;boardMsg('err',esc(String((e&&e.message)||e)));});});});}
836
+ api('/api/watch/draft',{id:n.id}).then(function(x){btn.disabled=false;if(!x||x.error){boardMsg('err',esc((x&&x.error)||'failed'));return;}boardMsg('ok',x.chat?DRAFTING_CHAT:DRAFTING);loadWatchUI();}).catch(function(e){btn.disabled=false;boardMsg('err',esc(String((e&&e.message)||e)));});});});}
817
837
  $('boardrefresh').addEventListener('click',loadBoard);$('boardhours').addEventListener('change',loadBoard);
818
838
  // A web page, mapped by itself in the hidden browser (headless.mjs): no capture,
819
839
  // no dragging. It takes a few seconds, so the button says so meanwhile.
@@ -823,6 +843,9 @@ api('/api/screens/map',{site:site,name:b.id==='mappage'?($('screenname').value||
823
843
  // Scroll: only looks, so it never asks. The part of the page it lands on is a new screen.
824
844
  function scrollUI(s,to,b){b.disabled=true;$('screenmsg').innerHTML="<div class='note muted'>Scrolling "+esc(to)+" and mapping&hellip;</div>";
825
845
  api('/api/screens/scroll',{id:s.id,to:to}).then(function(x){b.disabled=false;screenAdded(x);if(x&&!x.error)$('screenmsg').innerHTML="<div class='note ok'>&#10003; Scrolled "+esc(to)+" and mapped what's there: "+(x.regions||[]).length+" buttons, links and fields.</div>";}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});}
846
+ // Whole page: the hidden browser's window made as tall as the page, so one screen has all of it. Only looks, too.
847
+ function wholeUI(s,b){b.disabled=true;$('screenmsg').innerHTML="<div class='note muted'>Mapping the whole page in one tall screenshot&hellip;</div>";
848
+ api('/api/screens/whole',{id:s.id}).then(function(x){b.disabled=false;screenAdded(x);if(x&&!x.error)$('screenmsg').innerHTML=x.note?"<div class='note muted'>"+esc(x.note)+"</div>":"<div class='note ok'>&#10003; Mapped the whole page: "+(x.regions||[]).length+" buttons, links and fields, "+x.h+" pixels tall.</div>";}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});}
826
849
  // Press: a real click on the real site (signed in as you), so it asks first,
827
850
  // unless the site is one you trust (Settings).
828
851
  function pressUI(s,i,b){var r=(s.regions||[])[i];if(!r)return;
@@ -959,5 +982,5 @@ function appBar(p){var b=$('appbar');if(!b||!p.termux||window.SymbiotAndroid||ty
959
982
  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',{});}
960
983
  setInterval(heartbeat,4000);heartbeat(true);
961
984
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
962
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
985
+ initGraphEvents();syncP();refresh();loadMap();firstTab();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
963
986
  </script></body></html>`;