symbiot 0.63.6 → 0.63.8
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/CHANGELOG.md +41 -0
- package/README.md +39 -5
- package/agents.mjs +52 -11
- package/browser.html +21 -3
- package/capture.mjs +761 -0
- package/demo.mjs +171 -0
- package/guard.mjs +23 -5
- package/handback.mjs +22 -8
- package/handover.mjs +6 -2
- package/headless.mjs +281 -80
- package/home.mjs +23 -41
- package/index.mjs +65 -11
- package/lanes.mjs +99 -9
- package/links.mjs +52 -2
- package/marketing.mjs +40 -7
- package/mdns.mjs +6 -4
- package/package.json +6 -3
- package/phone.mjs +79 -12
- package/plain.mjs +48 -0
- package/reports.mjs +15 -1
- package/server.mjs +89 -42
- package/tray.mjs +44 -4
- package/ui.mjs +317 -47
package/home.mjs
CHANGED
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
// how you use them; this is the live data around them. Home's talk goes to the
|
|
11
11
|
// same Symbiot as every chat (mind.mjs converse), told what home shows.
|
|
12
12
|
import { claudeSetup } from "./claudesetup.mjs";
|
|
13
|
+
import { plainAsk } from "./plain.mjs";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import { homedir } from "node:os";
|
|
15
16
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
16
17
|
import { watchBoard, draftReply, draftCards, draftAnswer } from "./watch.mjs";
|
|
17
18
|
import { pendingReview, pushTasks, addTask, taskType } from "./tasks.mjs";
|
|
18
19
|
import { estimate, estimateWords } from "./estimate.mjs";
|
|
19
|
-
import { setHandoffCmd, agentsList, runHandoff, runningHandoff, handoffCmd, loadRuns, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
|
|
20
|
+
import { setHandoffCmd, agentsList, runHandoff, runningHandoff, handoffCmd, loadRuns, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach, unchangedStep } from "./agents.mjs";
|
|
20
21
|
import { linksState, signInAsked } from "./links.mjs";
|
|
21
22
|
import { knowledgeState } from "./knowledge.mjs";
|
|
22
23
|
import { CONFIG_DIR, loadTasks, saveTasks, loadConfig, saveConfig, sameTask } from "./core.mjs";
|
|
23
|
-
import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
|
|
24
|
+
import { lanesState, stuckHandovers, allowHandover, skipHandover, handingOf, loadLedger, doneSince } from "./lanes.mjs";
|
|
24
25
|
import { converse, actIn, actNow, taskIn, loadMind } from "./mind.mjs";
|
|
25
26
|
import { repoPathMap, laneMap, reposState, scanRoots } from "./scan.mjs";
|
|
26
27
|
import { resolveProvider, PROVIDERS, claudeState } from "./ai.mjs";
|
|
@@ -312,6 +313,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
312
313
|
const confirmed = deps.confirmed || (() => { try { return !!loadConfig().reposConfirmed; } catch { return false; } });
|
|
313
314
|
const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
|
|
314
315
|
const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
|
|
316
|
+
let led; const finished = deps.doneSince || ((path, q) => { try { return doneSince(path, q, { ledger: (led = led || loadLedger()), now }); } catch { return null; } });
|
|
315
317
|
// can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
|
|
316
318
|
const seesInbox = deps.seesInbox || (() => { try { return inboxSight(); } catch { return true; } });
|
|
317
319
|
// the agent the handoff command runs, gone from this computer; Claude connectors linked here but signed out
|
|
@@ -354,8 +356,12 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
354
356
|
const qs = (a.ask && a.ask.questions) || [], q = qs[0];
|
|
355
357
|
if (!q) continue;
|
|
356
358
|
const lane = laneOfPath(a.path, map), repo = lane || (lane === "" ? "" : a.name);
|
|
359
|
+
// the work it asks about is done already (another lane took it and ticked it): the card says so, and Close it is first
|
|
360
|
+
const pa = plainAsk(a.path, q), done = finished(a.path, q.q), opts = (q.options || []).slice(0, 2);
|
|
361
|
+
const closed = done ? { done, options: [`Close it: ${done.lane} has done this already (${done.text}). Check that it's so, and carry on without asking again. (recommended)`, opts[0]].filter(Boolean),
|
|
362
|
+
labels: ["Close it: it's done", ...(opts[0] ? [blobLabel(pa.labels ? pa.labels[0] : opts[0])] : [])] } : {};
|
|
357
363
|
you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
|
|
358
|
-
title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), ...
|
|
364
|
+
title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), ...pa, options: opts, ...closed, ...(qs.length > 1 ? { more: qs.length - 1 } : {}), ...signInCard([q.q, ...opts]), shape: "agents" });
|
|
359
365
|
}
|
|
360
366
|
// What stopped and can't go on without you, as a question too: a handover that
|
|
361
367
|
// couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
|
|
@@ -404,42 +410,8 @@ function noteAnswer(path, heading, text) {
|
|
|
404
410
|
const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
|
|
405
411
|
try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### ${String(heading).replace(/\s+/g, " ").trim()}\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
|
|
406
412
|
}
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
// becomes "a file". The answer sent back is still the choice's own text (labels only change what shows).
|
|
410
|
-
const PATHISH = /`?(?:~|\.{0,2})?\/?(?:[\w.-]+\/)+[\w.-]+\.(?:json|md|mjs|js|env|txt|sh)`?|`?\b[\w-]+\.(?:json|mjs)\b`?/g;
|
|
411
|
-
// What a rule lets an agent do, said the way you'd say it to someone who doesn't code.
|
|
412
|
-
const base = (p) => String(p || "").replace(/[/*]+$/, "").split("/").filter(Boolean).pop() || "your files";
|
|
413
|
-
function PLAIN_RULE(r) {
|
|
414
|
-
const m = String(r).match(/^(\w+)\((.*)\)$/); if (!m) return "work in " + base(r);
|
|
415
|
-
const [, tool, arg] = m, a = arg.replace(/:?\*+$/, "").trim(), first = a.split(/\s+/)[0].replace(/:\*?$/, "").split("/").pop();
|
|
416
|
-
if (tool === "Read") return `read files in ${base(a)}`;
|
|
417
|
-
if (/^(Edit|Write|MultiEdit)$/.test(tool)) return `change files in ${base(a)}`;
|
|
418
|
-
if (tool !== "Bash") return `use ${tool}`;
|
|
419
|
-
const host = (a.match(/@([\w.-]+)/) || [])[1];
|
|
420
|
-
if (/^(ssh|scp|rsync)$/.test(first)) return host ? `connect to the computer at ${host}` : "connect to another computer";
|
|
421
|
-
if (first === "adb") return "use your phone over the USB cable";
|
|
422
|
-
if (/^(flutter|gradle|gradlew|godot|xcodebuild)$/.test(first) || /\bbuild\b/.test(a)) return "build the app";
|
|
423
|
-
if (first === "ffmpeg" || first === "ffprobe") return "make and trim video clips";
|
|
424
|
-
if (first === "git" && /\bpush\b/.test(a)) return "send code to GitHub";
|
|
425
|
-
if (/^(npm|yarn|pnpm)$/.test(first) && /\bpublish\b/.test(a)) return "publish a new version";
|
|
426
|
-
if (/^(bash|sh|zsh)$/.test(first)) return "run a script it wrote";
|
|
427
|
-
if (/^(python\d*|node)$/.test(first)) return "run code in this project";
|
|
428
|
-
if (/^(rm|shred)$/.test(first)) return "delete files";
|
|
429
|
-
if (first === "sudo") return "make changes as administrator";
|
|
430
|
-
return `run ${first}`;
|
|
431
|
-
}
|
|
432
|
-
function plainAsk(path, q) {
|
|
433
|
-
const text = String(q.q || ""), opts = (q.options || []).slice(0, 2);
|
|
434
|
-
let ask = text, labels = opts.map((o) => o.replace(PATHISH, "a file"));
|
|
435
|
-
if (opts.some((o) => /allowlist\.proposed/i.test(o))) {
|
|
436
|
-
let rules = []; try { const p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; rules = [...new Set([...(p.allow || []).map(PLAIN_RULE), ...(p.additionalDirectories || []).map((d) => `work in ${base(d)}`)])]; } catch {}
|
|
437
|
-
ask = `${text.replace(PATHISH, "a file")}${rules.length ? ` It wants to ${rules.slice(0, 6).join(", ")}${rules.length > 6 ? `, and ${rules.length - 6} more things` : ""}.` : ""} If you say no, it skips that part and tells you what it left out.`;
|
|
438
|
-
labels = opts.map((o) => (/allowlist\.proposed/i.test(o) ? "Allow" + (/recommended/i.test(o) ? " (recommended)" : "") : o.replace(PATHISH, "a file")));
|
|
439
|
-
}
|
|
440
|
-
return { q: ask.replace(PATHISH, "a file"), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
|
|
441
|
-
}
|
|
442
|
-
|
|
413
|
+
// a label without its "(recommended)": a second button isn't the recommended one
|
|
414
|
+
const blobLabel = (l) => String(l || "").replace(/\s*\(recommended\)\s*$/i, "");
|
|
443
415
|
function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
444
416
|
id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
|
|
445
417
|
const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
|
|
@@ -466,7 +438,14 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
|
466
438
|
const settle = deps.settle || settleNeeds;
|
|
467
439
|
if (!go) { settle(a.path, n.key); dropped.add(id); return { ok: true, said: "Skipped. It won't ask again." }; }
|
|
468
440
|
if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
|
|
469
|
-
|
|
441
|
+
// Done it, on a step that names a file (`.env`): only once that file has changed since
|
|
442
|
+
// the run asked. In your own words it goes ahead (done some other way), its agent told.
|
|
443
|
+
const still = n.kind === "approve" ? [] : (deps.unchanged || unchangedStep)(`${n.what} ${n.check || ""}`, a.path, a.endedAt || a.startedAt || 0);
|
|
444
|
+
const last = still.map((f) => `\`${f.name}\` ${f.at ? `was last changed on ${new Date(f.at).toISOString().slice(0, 10)}` : "isn't there"}`).join(" and ");
|
|
445
|
+
if (still.length && !text) return { error: `${last.replace(/`/g, "")}, before its agent asked, so it isn't done yet. Make the change and press Done it again, or say how you did it.` };
|
|
446
|
+
const said = n.kind === "approve" ? `Go ahead: ${text || "do it as you had it, once you've checked what you flagged"}.`
|
|
447
|
+
: still.length ? `The user says it's done (${text}), but ${last}, before you asked. Check it yourself before relying on it, then carry on.`
|
|
448
|
+
: `Done: the user did it${text ? ` (${text})` : ""}. Check it worked, then carry on.`;
|
|
470
449
|
noteAnswer(a.path, n.what, said);
|
|
471
450
|
const e = (deps.run || runHandoff)(a.path, { force: true });
|
|
472
451
|
if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
|
|
@@ -670,9 +649,12 @@ function workScene({ deps = {}, full = false } = {}) {
|
|
|
670
649
|
const line = plain(String(t).split(/\n\s*\n/)[0], 220);
|
|
671
650
|
if (line) { p.summary = line; p.state = a.status === "running" ? "at work" : qs.length ? "asks you" : "done"; p.path = a.path; }
|
|
672
651
|
}
|
|
673
|
-
for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path, ...signInCard([q.q, ...(q.options || []).slice(0, 3)]) }); }
|
|
652
|
+
for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.shown || q.q, ...(q.shown ? { raw: q.q } : {}), options: (q.options || []).slice(0, 3), ...(q.labels ? { labels: q.labels } : {}), path: a.path, ...signInCard([q.q, ...(q.options || []).slice(0, 3)]) }); }
|
|
674
653
|
for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
|
|
675
654
|
}
|
|
655
|
+
// what each lane handed over and hasn't heard back on, with where it stands and how far the run on it has got
|
|
656
|
+
let led; const handing = deps.handing || ((path) => { try { return handingOf(path, { map, ledger: (led = led || loadLedger()) }); } catch { return []; } });
|
|
657
|
+
for (const [repo, path] of Object.entries(map)) { const h = handing(path); if (h.length) at(repo).handing = h.slice(0, 3); }
|
|
676
658
|
const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
|
|
677
659
|
const drafts = deps.drafts || (() => { try { return draftCards(); } catch { return []; } });
|
|
678
660
|
for (const t of troubles({ stuck: stuck(), list: AG, map, named, drafts: drafts() })) {
|
package/index.mjs
CHANGED
|
@@ -38,7 +38,7 @@ 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, wholePage, pressRegion, typeRegion, uploadRegion, uploadFiles, scrollPage, signIn, isTrusted } from "./headless.mjs";
|
|
41
|
+
import { mapPage, wholePage, pressRegion, typeRegion, chooseRegion, uploadRegion, uploadFiles, scrollPage, signIn, isTrusted } from "./headless.mjs";
|
|
42
42
|
import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
|
|
43
43
|
import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer, parsePairLink } from "./phone.mjs";
|
|
44
44
|
import { qrMatrix } from "./qr.mjs";
|
|
@@ -408,19 +408,22 @@ async function cmdScreens() {
|
|
|
408
408
|
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}`)}`);
|
|
409
409
|
return;
|
|
410
410
|
}
|
|
411
|
-
|
|
411
|
+
// whose tab in the app's hidden browser: the agent run's folder (SYMBIOT_AGENT), else here, so
|
|
412
|
+
// two agents at once each keep their own page (headless.mjs withPage)
|
|
413
|
+
const who = process.env.SYMBIOT_AGENT || process.cwd();
|
|
414
|
+
if (sub === "map") { const body = { site: a1, name: flag("name", ""), whole: has("whole"), who }; return out(screenJson((await viaApp("/api/screens/map", body)) || await mapPage(body.site, body.name, { whole: body.whole, who }))); }
|
|
412
415
|
if (sub === "whole") {
|
|
413
416
|
const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
|
|
414
|
-
return out(screenJson((await viaApp("/api/screens/whole", { id: s.id })) || await wholePage(s.id)));
|
|
417
|
+
return out(screenJson((await viaApp("/api/screens/whole", { id: s.id, who })) || await wholePage(s.id, { who })));
|
|
415
418
|
}
|
|
416
419
|
if (sub === "scroll") {
|
|
417
420
|
const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
|
|
418
|
-
const body = { id: s.id, to: a2 || "down" };
|
|
419
|
-
return out(screenJson((await viaApp("/api/screens/scroll", body)) || await scrollPage(body.id, body.to)));
|
|
421
|
+
const body = { id: s.id, to: a2 || "down", who };
|
|
422
|
+
return out(screenJson((await viaApp("/api/screens/scroll", body)) || await scrollPage(body.id, body.to, { who })));
|
|
420
423
|
}
|
|
421
424
|
if (sub === "show") { const s = find(a1); return out(s ? screenJson(s) : { error: "No screen " + (a1 || "") + ". symbiot screens lists them." }); }
|
|
422
425
|
if (sub === "signin") { const r = (await viaApp("/api/screens/signin", { site: a1 })) || await signIn(a1); return out(r.ok ? { ...r, next: "Sign in in the window that opened, close it, then map again." } : r); }
|
|
423
|
-
if (sub === "press" || sub === "type" || sub === "upload") {
|
|
426
|
+
if (sub === "press" || sub === "type" || sub === "choose" || sub === "upload") {
|
|
424
427
|
const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
|
|
425
428
|
const want = String(a2 || "").toLowerCase(), rs = s.regions || [];
|
|
426
429
|
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)));
|
|
@@ -428,9 +431,11 @@ async function cmdScreens() {
|
|
|
428
431
|
// a draft reply's agent (SYMBIOT_DRAFT, watch.mjs) never presses Send, or Enter (it sends in a chat)
|
|
429
432
|
// upload: the files resolved and checked here, where a relative path means something
|
|
430
433
|
const f = sub === "upload" ? uploadFiles([a3, ...more]) : null; if (f && f.error) return out(f);
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
+
// type --replace "old" "new": only that part of the field typed over
|
|
435
|
+
const part = sub === "type" && has("replace") ? { replace: String(a3 == null ? "" : a3), text: String(more[0] == null ? "" : more[0]) } : null;
|
|
436
|
+
const body = { id: s.id, region: r.id, confirmed: has("yes"), noSend: !!process.env.SYMBIOT_DRAFT, who, ...(sub === "type" ? (part || { text: a3 }) : {}), ...(sub === "type" ? { enter: has("enter") } : {}), ...(sub === "choose" ? { option: a3 } : {}), ...(f ? { files: f.files } : {}) };
|
|
437
|
+
const done = (await viaApp("/api/screens/" + (part ? "replace" : sub), body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend, who }) : sub === "type" ? await typeRegion(s.id, r.id, body.text, { enter: body.enter, confirmed: body.confirmed, noSend: body.noSend, who, ...(part ? { replace: part.replace } : {}) }) : sub === "choose" ? await chooseRegion(s.id, r.id, a3, { confirmed: body.confirmed, who }) : await uploadRegion(s.id, r.id, f.files, { confirmed: body.confirmed, who }));
|
|
438
|
+
if ((f || part || sub === "choose") && done && /answered 404/.test(done.error || "")) return out({ error: `The Symbiot app that's running is older than this command and can't ${f ? "upload" : part ? "replace part of a field" : "choose"}: restart it (symbiot app), then try again.` });
|
|
434
439
|
// not a trusted site: say how to go ahead (only you can trust a site, in the app's Settings)
|
|
435
440
|
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.` });
|
|
436
441
|
return out(screenJson(done));
|
|
@@ -445,6 +450,12 @@ async function cmdScreens() {
|
|
|
445
450
|
symbiot screens press <id> <region> [--yes] press a region there, map where it lands
|
|
446
451
|
symbiot screens type <id> <field> "text" [--enter] [--yes]
|
|
447
452
|
type into a field (Enter sends it), map the result
|
|
453
|
+
symbiot screens type <id> <field> --replace "old" "new" [--yes]
|
|
454
|
+
change only that part of what's in the field,
|
|
455
|
+
the rest (a post's picture too) left as it is
|
|
456
|
+
symbiot screens choose <id> <menu> "option" [--yes]
|
|
457
|
+
pick an option in a menu (a dropdown: MX in a
|
|
458
|
+
record type), by its label; map the result
|
|
448
459
|
symbiot screens upload <id> <field> <file> [<file>…] [--yes]
|
|
449
460
|
put a file in the page's file box (a picture or
|
|
450
461
|
video for a post): <field> is the box, or the
|
|
@@ -459,8 +470,9 @@ async function cmdScreens() {
|
|
|
459
470
|
symbiot browser [site] the Symbiot Browser: sign in to your sites for Symbiot
|
|
460
471
|
symbiot screens signin <site> sign in once, in the Symbiot Browser
|
|
461
472
|
--yes is needed (press, type, upload) unless the page's site is under Trusted sites in the app's Settings.
|
|
462
|
-
While the app runs, these use its hidden browser, which stays open a few minutes
|
|
463
|
-
press on the screen the last command printed carries on from that
|
|
473
|
+
While the app runs, these use its hidden browser, which stays open a few minutes, with
|
|
474
|
+
a tab for each agent: press on the screen the last command printed carries on from that
|
|
475
|
+
page as it is
|
|
464
476
|
(type without --enter, then press the form's button).`);
|
|
465
477
|
if (sub !== "help") process.exitCode = 1;
|
|
466
478
|
}
|
|
@@ -858,6 +870,44 @@ async function cmdMarketing() {
|
|
|
858
870
|
if (sub !== "help") process.exitCode = 1;
|
|
859
871
|
}
|
|
860
872
|
|
|
873
|
+
// ---- `symbiot capture`: a product's pictures and clips, for its pick tray (capture.mjs) ----
|
|
874
|
+
// Run by the app (it has the browser and ffmpeg, and the tray is its), else here. What
|
|
875
|
+
// Marketing's agent runs on a product's first run, instead of capture scripts of its own.
|
|
876
|
+
async function cmdCapture() {
|
|
877
|
+
const VAL = ["--url", "--window", "--screens", "--seconds"];
|
|
878
|
+
const [product = "help"] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !VAL.includes(all[i - 1]));
|
|
879
|
+
if (product === "help" || has("help")) {
|
|
880
|
+
console.log(`${c.b("symbiot capture")} ${c.d("— pictures and short clips of a product, for Marketing's pick tray")}
|
|
881
|
+
symbiot capture <product> [--url <its address>] [--screens /a,/b] [--seconds 6] [--no-clips]
|
|
882
|
+
a web app: its key screens (from its routes and
|
|
883
|
+
README), each a picture, the first few a clip too
|
|
884
|
+
symbiot capture <product> --window "<its window's title>" [--seconds 6]
|
|
885
|
+
a desktop app: its window, a picture and a clip
|
|
886
|
+
(under Wayland you pick it in your desktop's
|
|
887
|
+
screen-sharing prompt, the first time only)
|
|
888
|
+
Its address: --url, else the app of it running here (start it first, with demo data
|
|
889
|
+
where it has some). Symbiot itself: a copy with made-up work in it, started and
|
|
890
|
+
stopped for the capture, never your own.
|
|
891
|
+
Before anything reaches the tray, names, emails, phone numbers,
|
|
892
|
+
addresses, keys and tokens, account and card numbers, what's typed into forms,
|
|
893
|
+
notifications, chats, avatars and pictures are found and blurred. Look them over
|
|
894
|
+
under Marketing: switch a box off, or draw one of your own. The unblurred originals
|
|
895
|
+
stay in the lane's .symbiot/tray-originals/, never posted.`);
|
|
896
|
+
if (product !== "help") process.exitCode = 1;
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const body = { product, url: flag("url", ""), window: flag("window", ""), screens: String(flag("screens", "")).split(",").map((x) => x.trim()).filter(Boolean), clips: !has("no-clips"), ...(Number(flag("seconds", 0)) > 0 ? { seconds: Number(flag("seconds", 0)) } : {}) };
|
|
900
|
+
console.log(c.d(`Capturing ${product}: a minute or two…`));
|
|
901
|
+
const r = (await viaApp("/api/marketing/capture", body)) || await (await import("./capture.mjs")).captureProduct(body);
|
|
902
|
+
if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; return; }
|
|
903
|
+
console.log(`${c.g("✓")} ${r.note}`);
|
|
904
|
+
for (const it of r.items || []) console.log(c.d(` ${r.tray}/${it}`));
|
|
905
|
+
for (const s of r.skipped || []) console.log(c.y(` ${s.clip ? "no clip of" : "skipped"} ${s.path}: ${s.why}`));
|
|
906
|
+
for (const f of r.failed || []) console.log(c.y(` not blurred yet: ${f}`));
|
|
907
|
+
if (r.warning) console.log(c.y(r.warning));
|
|
908
|
+
if (r.seed) console.log(c.d(` It has demo data (${r.seed}): capture with that loaded, not real data.`));
|
|
909
|
+
}
|
|
910
|
+
|
|
861
911
|
// ---- `symbiot reports`: what agents wrote up for you (reports.mjs) ---------------
|
|
862
912
|
function cmdReports() {
|
|
863
913
|
const id = argv.slice(1).find((x) => !x.startsWith("--"));
|
|
@@ -910,6 +960,9 @@ ${c.b("Experimental")}
|
|
|
910
960
|
symbiot marketing posted <draft> --url <link>
|
|
911
961
|
mark a Marketing draft posted (or superseded);
|
|
912
962
|
symbiot marketing help for more
|
|
963
|
+
symbiot capture <product> pictures and clips of a product for Marketing's
|
|
964
|
+
pick tray, blurred where they show anything private
|
|
965
|
+
(symbiot capture help for more)
|
|
913
966
|
symbiot screens map <site> map a web page's buttons in a hidden browser
|
|
914
967
|
(symbiot screens help for more)
|
|
915
968
|
symbiot watch add <screen id> keep track of a mapped page: what's new on it
|
|
@@ -963,6 +1016,7 @@ async function main() {
|
|
|
963
1016
|
if (cmd === "phone") return cmdPhone();
|
|
964
1017
|
if (cmd === "post" || cmd === "posts") return cmdPost();
|
|
965
1018
|
if (cmd === "marketing") return cmdMarketing();
|
|
1019
|
+
if (cmd === "capture") return cmdCapture();
|
|
966
1020
|
if (cmd === "knowledge" || cmd === "know") return cmdKnowledge();
|
|
967
1021
|
if (cmd === "reports" || cmd === "report") return cmdReports();
|
|
968
1022
|
if (cmd === "week")return cmdRun("week");
|
package/lanes.mjs
CHANGED
|
@@ -14,15 +14,17 @@
|
|
|
14
14
|
// result?, reportedAt? }] }. status: started, held (that lane's agent was busy:
|
|
15
15
|
// it starts when it's free), blocked (it didn't start, and nothing running there will
|
|
16
16
|
// start it: note says why), done (reported back) or error (reported back too).
|
|
17
|
-
// chain: how many handovers in a row led here; past MAX_CHAIN
|
|
18
|
-
// lanes can't hand the same thing back and forth forever.
|
|
17
|
+
// chain: how many handovers in a row led here; past MAX_CHAIN no agent is started on
|
|
18
|
+
// it, so two lanes can't hand the same thing back and forth forever. To a repo it's
|
|
19
|
+
// still added to that lane's tasks (blocked), for its next run: dropped, ops's
|
|
20
|
+
// User-Agent fix for symbiot was only found in lanes.json (2026-10-12).
|
|
19
21
|
import { join, basename, dirname, resolve, relative } from "node:path";
|
|
20
22
|
import { homedir } from "node:os";
|
|
21
23
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodSync, statSync } from "node:fs";
|
|
22
24
|
import { createHash, randomBytes } from "node:crypto";
|
|
23
25
|
import { execFile } from "node:child_process";
|
|
24
26
|
import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
|
|
25
|
-
import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist, writeTasks } from "./agents.mjs";
|
|
27
|
+
import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist, writeTasks, workOf } from "./agents.mjs";
|
|
26
28
|
import { addTask, removeTask, pushTasks } from "./tasks.mjs";
|
|
27
29
|
import { actNow, actBrief } from "./mind.mjs";
|
|
28
30
|
import { laneMap } from "./scan.mjs";
|
|
@@ -86,7 +88,7 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
|
|
|
86
88
|
const from = { lane: laneOf(path, map), path }, parent = ledger.handoffs.filter((h) => h.to.path === path).pop(), chain = (parent ? parent.chain : 0) + 1;
|
|
87
89
|
const started = [];
|
|
88
90
|
for (const h of asked) {
|
|
89
|
-
const key =
|
|
91
|
+
const key = keyOf(path, h);
|
|
90
92
|
if (ledger.handoffs.some((x) => x.key === key || (x.was || []).includes(key))) continue;
|
|
91
93
|
const prior = supersedes(h, from, ledger, { running, tasks });
|
|
92
94
|
if (prior) {
|
|
@@ -106,8 +108,8 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
|
|
|
106
108
|
started.push(prior); continue;
|
|
107
109
|
}
|
|
108
110
|
const e = { id: randomBytes(4).toString("hex"), key, from, to: { lane: h.lane, path: "" }, text: h.text, at: now, chain, status: "started" };
|
|
109
|
-
const to = laneTarget(h.lane, map);
|
|
110
|
-
if (
|
|
111
|
+
const to = laneTarget(h.lane, map), looped = chain > MAX_CHAIN;
|
|
112
|
+
if (looped && (!to || to.lane === OPS || to.lane.toLowerCase() === from.lane.toLowerCase())) Object.assign(e, { status: "error", error: `${MAX_CHAIN} handovers in a row led here, so this one wasn't started. Do it yourself, or ask the user.` });
|
|
111
113
|
else if (!to) Object.assign(e, { status: "error", error: `There's no lane called ${h.lane}. Lanes: ops, or a repo: ${Object.keys(map).slice(0, 30).join(", ")}.` });
|
|
112
114
|
else if (to.lane.toLowerCase() === from.lane.toLowerCase()) Object.assign(e, { status: "error", error: `${to.lane} is your own lane: do it yourself.` });
|
|
113
115
|
else if (to.lane === OPS) {
|
|
@@ -119,12 +121,13 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
|
|
|
119
121
|
if (t.error) Object.assign(e, { status: "error", error: t.error });
|
|
120
122
|
else {
|
|
121
123
|
push({ repo: to.lane });
|
|
122
|
-
|
|
123
|
-
Object.assign(e, { to, task: t.id, ...startedAs(r) });
|
|
124
|
+
if (looped) Object.assign(e, { to, task: t.id, status: "blocked", note: `${MAX_CHAIN} handovers in a row led here, so no agent was started on it: it waits in ${to.lane}'s tasks for its next run` });
|
|
125
|
+
else { const r = run(to.path) || {}; Object.assign(e, { to, task: t.id, ...startedAs(r) }); }
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
ledger.handoffs.push(e); started.push(e);
|
|
127
129
|
}
|
|
130
|
+
noteSent(path, asked, ledger);
|
|
128
131
|
return started;
|
|
129
132
|
}
|
|
130
133
|
|
|
@@ -179,6 +182,73 @@ function partlyDone(path, { ledger = loadLedger() } = {}) {
|
|
|
179
182
|
for (const h of parseHandoffs(readSym(path, "HANDOFF.md"))) if (!mine.some((x) => x.to.lane.toLowerCase() === h.lane.toLowerCase() && x.text === h.text)) handed.push({ lane: h.lane, text: clipWords(firstLine(h.text), 90) });
|
|
180
183
|
return questions || handed.length ? { questions, handed: handed.slice(0, 3) } : null;
|
|
181
184
|
}
|
|
185
|
+
// What a folder handed over that hasn't come back yet, each with where it stands,
|
|
186
|
+
// for that lane's block (Agents, Home): the sections of its HANDOFF.md not sent
|
|
187
|
+
// yet, and the ledger's handovers from it, with how far the receiving run has got.
|
|
188
|
+
// whatsapp_module's test-number handover sat from 07:33 (2026-10-12) behind ops's
|
|
189
|
+
// run on its earlier version, and nothing on its block said so, so the user
|
|
190
|
+
// answered as if ops already had it. [{ lane, text, gone, why, doing?, progress? }]:
|
|
191
|
+
// gone, its lane has it (why says how it's going); otherwise why says what it waits on.
|
|
192
|
+
const keyOf = (path, h) => createHash("sha1").update(path + "\n" + h.lane.toLowerCase() + "\n" + h.text).digest("hex").slice(0, 16);
|
|
193
|
+
const findSent = (ledger, key) => ledger.handoffs.slice().reverse().find((x) => x.key === key) || ledger.handoffs.slice().reverse().find((x) => (x.was || []).includes(key));
|
|
194
|
+
// What the run there is doing and how far through its tasks it is.
|
|
195
|
+
function runWork(p) { try { const { work, progress } = workOf(p, readSym(p, "agent.log")); const t = work && (work.todos || []).find((x) => x.status === "in_progress"); return { doing: clipWords(String((t && t.active) || (work && work.doing) || ""), 100), progress }; } catch { return {}; } }
|
|
196
|
+
function handingOf(path, { ledger, map, running = runningHandoff, now = Date.now(), changedAt, work = runWork } = {}) {
|
|
197
|
+
if (!path) return [];
|
|
198
|
+
ledger = ledger || loadLedger();
|
|
199
|
+
const asked = parseHandoffs(readSym(path, "HANDOFF.md"));
|
|
200
|
+
const mine = ledger.handoffs.filter((x) => x.from.path === path && !x.skipped && now - (x.at || 0) < STUCK_FOR && (x.status === "error" ? !pastIt(x, ledger.handoffs) : !x.reportedAt && x.status !== "done")); // one that couldn't go stays until you Allow or Skip it
|
|
201
|
+
if (!asked.length && !mine.length) return [];
|
|
202
|
+
map = map || laneMap();
|
|
203
|
+
if (changedAt == null) try { changedAt = statSync(join(path, ".symbiot", "HANDOFF.md")).mtimeMs; } catch { changedAt = now; }
|
|
204
|
+
const scanned = path.startsWith(ACT_DIR) || Object.values(map).includes(path), out = [];
|
|
205
|
+
const short = (t) => clipWords(firstLine(t).replace(/\*\*/g, ""), 120);
|
|
206
|
+
for (const h of asked) {
|
|
207
|
+
if (findSent(ledger, keyOf(path, h))) continue; // sent: the ledger's row says where it stands
|
|
208
|
+
const age = now - changedAt, row = (why) => out.push({ lane: h.lane, text: short(h.text), gone: false, why });
|
|
209
|
+
if (!scanned) row("Not sent: Symbiot doesn't have this folder as a lane, so it never reads its HANDOFF.md");
|
|
210
|
+
else if (age < 60000) row("Not sent yet: Symbiot picks it up within 20 seconds");
|
|
211
|
+
else row(`Not sent after ${ago(age)}: Symbiot only sends handovers while its app is open`);
|
|
212
|
+
}
|
|
213
|
+
for (const e of mine.slice().reverse()) {
|
|
214
|
+
const row = (gone, why, more = {}) => out.push({ lane: e.to.lane, text: short(e.text), gone, why, ...more });
|
|
215
|
+
if (e.status === "error") { row(false, `Couldn't hand it over: ${e.error || "it didn't start"}`); continue; }
|
|
216
|
+
const p = e.to.path, going = !!(p && running(p)), began = p ? lastRunStart(p) : 0;
|
|
217
|
+
if (began < e.at && going) row(true, e.superseded ? `Sent: ${e.to.lane} is on the earlier version now (its run started ${ago(now - began)} ago), and gets this one when that run ends` : `Sent, and queued: ${e.to.lane}'s agent is busy, and starts on it when that run ends`);
|
|
218
|
+
else if (began < e.at && e.status === "blocked") row(false, `Sent, but it didn't start: ${e.note || "its agent didn't start"}`);
|
|
219
|
+
else if (began < e.at) row(true, `Sent: ${e.to.lane}'s agent starts on it when it's free`);
|
|
220
|
+
else if (going) { const w = work(p) || {}; row(true, "Sent: its agent is on it now", { ...(w.doing ? { doing: w.doing } : {}), ...(w.progress ? { progress: w.progress } : {}) }); }
|
|
221
|
+
else if (openQuestions(p)) row(true, "Sent: its agent asked you something first");
|
|
222
|
+
else row(true, "Sent: its run ended, and what it did comes back within 20 seconds");
|
|
223
|
+
}
|
|
224
|
+
return out.slice(0, 6);
|
|
225
|
+
}
|
|
226
|
+
const ago = (ms) => { const m = Math.round(ms / 60000); return m < 1 ? "under a minute" : m < 120 ? `${m} min` : `${Math.round(m / 60)} hours`; };
|
|
227
|
+
// Once what's in a HANDOFF.md has gone, the file says so instead of still reading
|
|
228
|
+
// like a handover waiting to go: whatsapp_module's agent saw its file still there
|
|
229
|
+
// and told the user it hadn't been sent, while ops had both its handovers running
|
|
230
|
+
// (2026-10-12). The sections become a dated list of what went where, newest first;
|
|
231
|
+
// a new "### <lane>" section added to it is sent as usual.
|
|
232
|
+
const stamp = (t) => { const d = new Date(t), p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; };
|
|
233
|
+
function sentState(e, key) {
|
|
234
|
+
if (e.status === "error") return `couldn't hand it over: ${String(e.error || "it didn't start").replace(/\.$/, "")}`;
|
|
235
|
+
if (e.key !== key) return "sent earlier, and replaced since by a newer version";
|
|
236
|
+
if (e.superseded) return e.to.lane === OPS ? "replaces the version sent earlier: ops's run on that one gets it when it ends" : `replaces the version sent earlier, as a task in ${e.to.lane}`;
|
|
237
|
+
if (e.status === "held") return `queued in ${e.to.lane}: its agent was busy, and starts on it when that run ends`;
|
|
238
|
+
if (e.status === "blocked") return `added to ${e.to.lane}'s tasks, but its agent didn't start (${String(e.note || "").replace(/\.$/, "")})`;
|
|
239
|
+
return `${e.to.lane}'s agent started on it`;
|
|
240
|
+
}
|
|
241
|
+
function noteSent(path, asked, ledger) {
|
|
242
|
+
const file = join(path, ".symbiot", "HANDOFF.md");
|
|
243
|
+
const rows = asked.map((h) => { const key = keyOf(path, h), e = findSent(ledger, key); return e ? `- ${stamp(e.at || Date.now())} → **${e.to.lane}**: ${sentState(e, key)}. "${clipWords(firstLine(h.text).replace(/\*\*/g, ""), 100)}"` : null; });
|
|
244
|
+
if (!rows.length || rows.some((r) => !r)) return false;
|
|
245
|
+
const had = readSym(path, "HANDOFF.md");
|
|
246
|
+
if (JSON.stringify(parseHandoffs(had)) !== JSON.stringify(asked)) return false; // its agent is writing to it: next pass
|
|
247
|
+
const lines = had.split("\n"), at = lines.findIndex((l) => /^##\s+Sent\s*$/.test(l)), before = [];
|
|
248
|
+
if (at >= 0) for (const l of lines.slice(at + 1)) { if (/^#/.test(l)) break; if (/^- /.test(l)) before.push(l); }
|
|
249
|
+
const md = `# Handovers: sent\nSymbiot sent what was in this file to its lane, so nothing here waits to go. Each lane's agent works on it (the Agents tab shows how far it has got), and what it did comes back to you in \`.symbiot/ANSWERS.md\`. To hand over something else, or a changed version of one below, add a \`### <lane>\` section to this file: Symbiot sends it within 20 seconds.\n\n## Sent\n${[...rows, ...before].slice(0, 12).join("\n")}\n`;
|
|
250
|
+
try { writeFileSync(file, md); return true; } catch { return false; }
|
|
251
|
+
}
|
|
182
252
|
// Tell the agent that asked: the result as an answer in its ANSWERS.md, then
|
|
183
253
|
// start it again to carry on (unless it's still running: it reads it next time).
|
|
184
254
|
// Not past a step of the user's it waits on (a key for .env, "don't start another
|
|
@@ -231,6 +301,26 @@ function alike(a, b) {
|
|
|
231
301
|
let n = 0; for (const w of s) if (l.has(w)) n++;
|
|
232
302
|
return n / s.size >= 0.5;
|
|
233
303
|
}
|
|
304
|
+
// A lane's question about work it handed over, once that work is done: whatsapp_module
|
|
305
|
+
// asked "Can I reach sts-prod myself to put the WhatsApp module back?" after its two
|
|
306
|
+
// handovers to ops stopped, and kept asking on Home after ops had put it back and ticked
|
|
307
|
+
// it (2026-10-12). It's out of date when three quarters of its words (sts-prod counts
|
|
308
|
+
// whole) are in one of this lane's handovers from the last week, the one it's most about,
|
|
309
|
+
// and the lane that took it has ticked it in its TASKS.md. { lane, text } for the card, or null.
|
|
310
|
+
const DONE_WITHIN = 7 * 86400000, ASK_SKIP = new Set(["can", "the", "and", "for", "you", "now", "how", "its", "it's", "myself", "yourself", ...COMMON]);
|
|
311
|
+
const askWords = (s) => new Set(String(s || "").toLowerCase().replace(/[^a-z0-9\s'-]/g, " ").split(/\s+/).map((w) => w.replace(/^[-']+|[-']+$/g, "")).filter((w) => w.length > 2 && !ASK_SKIP.has(w)).map((w) => w.replace(/s$/, "")));
|
|
312
|
+
function doneSince(path, q, { ledger = loadLedger(), now = Date.now(), read = readSym } = {}) {
|
|
313
|
+
const want = askWords(q); if (!path || want.size < 3) return null;
|
|
314
|
+
let best = null, top = 0.75; // the handover it's most about, newest first on a tie
|
|
315
|
+
for (const h of ledger.handoffs.slice().reverse()) {
|
|
316
|
+
if (h.from.path !== path || h.skipped || h.status === "error" || !h.to.path || now - (h.at || 0) > DONE_WITHIN) continue;
|
|
317
|
+
const has = askWords(firstLine(h.text)); let n = 0; for (const w of want) if (has.has(w)) n++;
|
|
318
|
+
if (n / want.size > top || (!best && n / want.size === top)) { best = h; top = n / want.size; }
|
|
319
|
+
}
|
|
320
|
+
if (!best) return null;
|
|
321
|
+
const head = firstLine(best.text), ticked = read(best.to.path, "TASKS.md").split("\n").filter((l) => /^\s*(?:[-*+]|\d+[.)])\s*\[[xX]\]/.test(l));
|
|
322
|
+
return ticked.some((l) => alike(l, head)) ? { lane: best.to.lane, text: clipWords(head.replace(/\*\*/g, "").replace(/^What['’]s needed:\s*/i, ""), 120) } : null;
|
|
323
|
+
}
|
|
234
324
|
// The folders a handover names that a run would need: ones on this computer, not
|
|
235
325
|
// the asking run's own, not Symbiot's, not your home itself or a hidden one in it
|
|
236
326
|
// (~/.ssh). A file named in one counts as the folder it's in. Outermost only, 3 at most.
|
|
@@ -352,4 +442,4 @@ function lanesState() {
|
|
|
352
442
|
return { handoffs: out.slice(0, 30) };
|
|
353
443
|
}
|
|
354
444
|
|
|
355
|
-
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone, remoteKey, orcaMoves, orcaRelink, stuckHandovers, allowHandover, skipHandover, namedDirs, alike };
|
|
445
|
+
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, handingOf, lanesTick, lanesState, aboutIt, partlyDone, remoteKey, orcaMoves, orcaRelink, stuckHandovers, allowHandover, skipHandover, namedDirs, alike, doneSince };
|
package/links.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { readFileSync } from "node:fs";
|
|
|
24
24
|
import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
|
|
25
25
|
import { siteUrl, signIn, readPage, trustedSites, trustSite, untrustSite } from "./headless.mjs";
|
|
26
26
|
import { watchState, addWatch, removeWatch, checkWatch, GITHUB_INBOX } from "./watch.mjs";
|
|
27
|
+
import { loadRuns, agentQuestions, answerQuestions } from "./agents.mjs";
|
|
27
28
|
|
|
28
29
|
// The standard sites. url is where you sign in and, with watch, the page that's
|
|
29
30
|
// read for what's new; hosts are trusted along with it.
|
|
@@ -136,12 +137,61 @@ async function checkLink(id, { check = checkWatch, read = readPage } = {}) {
|
|
|
136
137
|
if (l[id].watch) { const r = await check(l[id].watch); if (r && r.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) }; }
|
|
137
138
|
else {
|
|
138
139
|
const p = await read(e.url, { password: true });
|
|
139
|
-
if (p && p.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) };
|
|
140
|
+
if (p && (p.busy || WINDOW_OPEN.test(p.error || ""))) return { busy: true, item: linksState().items.find((x) => x.id === id) };
|
|
140
141
|
const l2 = linked(); if (!l2[id]) return { error: `${e.name} isn't linked.` };
|
|
141
142
|
l2[id] = { ...l2[id], ok: !!(p && !p.error && !p.login), checked: Date.now() }; saveLinked(l2);
|
|
142
143
|
}
|
|
143
144
|
return { ok: true, item: linksState().items.find((x) => x.id === id) };
|
|
144
145
|
}
|
|
146
|
+
const WINDOW_OPEN = /Symbiot Browser is open/;
|
|
147
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
148
|
+
// Once the Symbiot Browser closes (Done, or its X: server.mjs): each linked site not read
|
|
149
|
+
// signed in yet is read now. One the hidden browser can't take yet (an agent's page open in
|
|
150
|
+
// it, or the window not quite gone) is tried again a little later, so a check never just
|
|
151
|
+
// doesn't happen. Gives the items read signed in.
|
|
152
|
+
async function checkSignIns({ check = checkLink, tries = 6, wait = 10000 } = {}) {
|
|
153
|
+
let todo = linksState().items.filter((x) => x.state !== "off" && x.state !== "ok").map((x) => x.id);
|
|
154
|
+
const now = [];
|
|
155
|
+
for (let i = 0; i < tries && todo.length; i++) {
|
|
156
|
+
if (i) await sleep(wait);
|
|
157
|
+
const again = [];
|
|
158
|
+
for (const id of todo) {
|
|
159
|
+
let r = null; try { r = await check(id); } catch {}
|
|
160
|
+
const it = r && r.item;
|
|
161
|
+
if (it && it.state === "ok") now.push(it);
|
|
162
|
+
else if (r && (r.busy || (it && WINDOW_OPEN.test(it.note || "")))) again.push(id);
|
|
163
|
+
}
|
|
164
|
+
todo = again;
|
|
165
|
+
}
|
|
166
|
+
return now;
|
|
167
|
+
}
|
|
168
|
+
// A question an agent left asking you to sign in to a site ("sign in to www.reddit.com in the
|
|
169
|
+
// Symbiot Browser, then click Done"), once that site reads signed in: answered for you, and its
|
|
170
|
+
// agent carries on. Before, it sat on Home after the sign-in it asked for was done. Gives
|
|
171
|
+
// [{ path, q }] answered.
|
|
172
|
+
function settleSignIns(items, { paths, ask = agentQuestions, answer = answerQuestions } = {}) {
|
|
173
|
+
const list = catalog().list, hosts = (items || []).flatMap((x) => { const e = list.find((c) => c.id === x.id); return e ? e.hosts.map((h) => ({ h, name: e.name })) : []; });
|
|
174
|
+
if (!hosts.length) return [];
|
|
175
|
+
const on = (site) => { const h = hostOf(siteUrl(site)); return h.includes(".") && hosts.find((x) => h === x.h || h.endsWith("." + x.h) || x.h.endsWith("." + h)); };
|
|
176
|
+
if (!paths) { try { paths = [...new Set(loadRuns().map((r) => r.path).filter(Boolean))]; } catch { paths = []; } }
|
|
177
|
+
const done = [];
|
|
178
|
+
for (const path of paths) {
|
|
179
|
+
let qs = []; try { qs = ask(path, "").questions || []; } catch {}
|
|
180
|
+
const rows = qs.map((q) => { const s = signInAsked([q.q, ...(q.options || [])].join("\n")), x = s && on(s.site); return x ? { q: q.q, a: `Done: you're signed in to ${x.name} in the Symbiot Browser now (Symbiot read it signed in just now), so carry on with it.` } : null; }).filter(Boolean);
|
|
181
|
+
if (!rows.length) continue;
|
|
182
|
+
let r = null; try { r = answer(path, rows.map((x) => ({ q: x.q, a: x.a })), { rerun: true }); } catch {}
|
|
183
|
+
if (r && r.ok) done.push(...rows.map((x) => ({ path, q: x.q })));
|
|
184
|
+
}
|
|
185
|
+
return done;
|
|
186
|
+
}
|
|
187
|
+
// Done in the Symbiot Browser, its X, or its last window going (server.mjs): the sites are
|
|
188
|
+
// checked, and the questions that settles answered. One at a time, so Done and the window
|
|
189
|
+
// closing because of it are checked once. Gives { signedIn, answered }.
|
|
190
|
+
let CHECKING = null;
|
|
191
|
+
function sitesCheck({ check = checkSignIns, settle = settleSignIns, after = 1500 } = {}) {
|
|
192
|
+
return CHECKING || (CHECKING = sleep(after).then(() => check()).then((ok) => ({ signedIn: ok, answered: settle(ok) }))
|
|
193
|
+
.catch(() => ({ signedIn: [], answered: [] })).finally(() => { CHECKING = null; }));
|
|
194
|
+
}
|
|
145
195
|
// Unlink: stop watching it, and untrust the hosts this link trusted (never ones
|
|
146
196
|
// you trusted yourself). A host another link still uses (Outlook and Outlook
|
|
147
197
|
// Calendar share one) passes to that link instead, so it goes when the last
|
|
@@ -191,4 +241,4 @@ function signInAsked(text) {
|
|
|
191
241
|
return null;
|
|
192
242
|
}
|
|
193
243
|
|
|
194
|
-
export { CATALOG, catalog, cleanEntry, linksState, linkSite, checkLink, unlinkSite, addSite, ownSites, signInAsked, OWN };
|
|
244
|
+
export { CATALOG, catalog, cleanEntry, linksState, linkSite, checkLink, checkSignIns, settleSignIns, sitesCheck, unlinkSite, addSite, ownSites, signInAsked, OWN };
|