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/README.md +59 -5
- package/agents.mjs +84 -17
- package/desktop.mjs +6 -4
- package/headless.mjs +120 -17
- package/index.mjs +38 -16
- package/package.json +1 -1
- package/screens.mjs +18 -4
- package/server.mjs +12 -9
- package/tasks.mjs +37 -5
- package/ui.mjs +37 -14
- package/watch.mjs +81 -25
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,
|
|
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
|
|
85
|
-
const
|
|
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") } : {
|
|
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]
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 · 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 "{prompt}" --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 — 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&¤t==='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
|
-
|
|
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 · <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'>↶</button>":"<button class='rm' title='remove'>×</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'>↶</button>":"<button class='rm' title='remove'>×</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 — wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
|
|
584
590
|
if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>✓ <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span>"+(w.held?" <span class='ev'>⏸ held: an agent is still running there, so these land when it finishes</span>":"")+"</li>";});h+="</ul>";}
|
|
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'>⚠ not sent: "+esc(names)+". "+(hasNoRepo?"Pick a repo in the dropdown next to <b>Add</b> so the task has somewhere to go.":"That repo isn't in the map — add its folder in Settings.")+"</div>";}
|
|
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="⚠ <b>"+esc(w.name)+"</b> already has an agent running, so another wasn't started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'>🤖 Handed "+n+" repo(s) to your agent — opening the <b>Agents</b> tab to watch it work…</div>";setTimeout(function(){setTab('agents');},500);}
|
|
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="⏸ <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?"✓ Started an agent in <b>"+esc(w.name)+"</b>.":"⚠ <b>"+esc(w.name)+"</b>: "+(y&&y.busy?"an agent is already running there.":"it didn't start. Check the command in Settings.");});});o.appendChild(d);}if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="⚠ <b>"+esc(w.name)+"</b> already has an agent running, so another wasn't started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'>🤖 Handed "+n+" repo(s) to your agent — opening the <b>Agents</b> tab to watch it work…</div>";setTimeout(function(){setTab('agents');},500);}
|
|
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'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="🔌 Claude in Orca's tab asks you before it uses your connectors ("+CONNECTORS.map(nm).join(", ")+").";return;}
|
|
603
|
+
if(!/^\\s*claude\\b/.test(cmd)){el.innerHTML="🔌 Your Claude connectors ("+CONNECTORS.map(nm).join(", ")+") reach Claude Code runs only. This command isn't Claude, so its runs can't use them.";return;}
|
|
604
|
+
el.innerHTML=(ready.length?"🔌 Your agent's runs can use "+ready.map(nm).join(", ")+": linked to Claude, so each run allows their tools.":"")+(wait.length?(ready.length?" ":"🔌 ")+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 — type your own command above.</span>";return;}
|
|
@@ -599,7 +613,7 @@ var h="<span class='muted' style='font-size:12px'>Detected — click to use:
|
|
|
599
613
|
if(chips.length>na)h+="<div class='muted' style='font-size:12px;margin-top:8px'>Editors — 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'>×</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)+")":"")+" · ":"")+s.w+" × "+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's new: click to stop'>Watching ✓</button>":"<button class='ghost' id='scrwatch' title='read this page again every few minutes while Symbiot runs, and tell me what'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?" · <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's there as a new screen'>↑ Scroll up</button>":"")+(down?"<button class='ghost scrscroll' data-to='down' title='scroll down in the hidden browser and map what's there as a new screen'>Scroll down ↓</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?" · <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?" · <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's there as a new screen'>↑ Scroll up</button>":"")+(down?"<button class='ghost scrscroll' data-to='down' title='scroll down in the hidden browser and map what's there as a new screen'>Scroll down ↓</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 ↕</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 · 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)+" · "+b0.count+" new · "+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 · "+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)+" · "+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 · 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 · "+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)+" · "+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 · 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?"✓ "+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's hidden browser and types a reply into its message box, unsent. It never presses Send or Enter";
|
|
807
|
+
var DRAFTING_CHAT="✓ 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="✓ 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:'✉️',github:'🔔',chat:'💬',page:'🌐'};
|
|
@@ -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
|
|
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')+" · "+(c.checked?"read "+agoTxt(c.checked):c.last?"tried "+agoTxt(c.last):"first read within a minute")+(c.via==='gh'?" · 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 · 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 · again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
|
|
809
827
|
+(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>…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…</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'>✓ 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…</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'>✓ 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>`;
|