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/README.md
CHANGED
|
@@ -58,8 +58,11 @@ Tabs:
|
|
|
58
58
|
- **Dashboard** — everything you [watch](#screens-blueprints-for-screen-automation)
|
|
59
59
|
side by side: a card for each page (your inbox, GitHub, WhatsApp, any other) with
|
|
60
60
|
how many new since yesterday (or in the last 3 or 7 days), its latest brief, the
|
|
61
|
-
newest items, and **Open**, **Draft a reply** and **Check now** on them.
|
|
62
|
-
|
|
61
|
+
newest items, and **Open**, **Draft a reply** and **Check now** on them. **Seen**
|
|
62
|
+
on a card sets its count back to 0 (what it found stays under Watching, and the
|
|
63
|
+
other cards keep theirs). The tab shows the count, so you see what's waiting from
|
|
64
|
+
any tab, and once you watch something the app opens on the Dashboard instead of
|
|
65
|
+
the Map. `symbiot watch board` prints the same cards as JSON.
|
|
63
66
|
- **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
|
|
64
67
|
a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
|
|
65
68
|
- **Week / Standup / Todo** — the write-ups (these use your chosen AI).
|
|
@@ -108,7 +111,8 @@ repo is now archived), without Electron:
|
|
|
108
111
|
default). The app writes your week with your chosen AI, saves it to
|
|
109
112
|
`~/.config/symbiot/weeks/<date>.md`, and sends a desktop notification
|
|
110
113
|
(`notify-send` on Linux, Notification Center on macOS, a tray balloon on
|
|
111
|
-
Windows). The Week tab shows it until you write a new one
|
|
114
|
+
Windows). The Week tab shows it until you write a new one, and a week you
|
|
115
|
+
write there with its button is saved to `weeks/` the same way. It runs while
|
|
112
116
|
`symbiot app` is running. A week missed while the computer was off is written
|
|
113
117
|
when the app next starts, once. **Write it now** checks that it works.
|
|
114
118
|
- **Start Symbiot in the background when I log in** adds an autostart entry
|
|
@@ -263,6 +267,16 @@ Tasks waiting for review aren't re-sent to the agent, and while the repo's agent
|
|
|
263
267
|
is still running, Approve waits ("agent still working"), so half-done work isn't
|
|
264
268
|
committed.
|
|
265
269
|
|
|
270
|
+
**Tasks about tasks.** An agent can't edit your task list, so a task like
|
|
271
|
+
`Drop the "gosolr's own WhatsApp number" task` or ``Merge the two `WA_WABA_ID`
|
|
272
|
+
tasks into one`` is done once you approve it. The tasks it names then leave your
|
|
273
|
+
list, so a later **Send to repos** doesn't bring them back: a dropped one is
|
|
274
|
+
archived, and of merged ones the newest stays and the rest are archived. Symbiot
|
|
275
|
+
finds them by the quoted name before the word "task": tasks in that repo, from
|
|
276
|
+
before the approval, with all of its words ("'s" aside). If it names more than
|
|
277
|
+
four, it's too vague, and nothing is removed. In the archived view they're marked
|
|
278
|
+
**dropped** or **merged**, and one you restore stays.
|
|
279
|
+
|
|
266
280
|
**Releases.** The review card warns about merged work that isn't released yet,
|
|
267
281
|
measured the way the repo releases: from its last `v*` tag, or, for a repo whose
|
|
268
282
|
GitHub workflow runs `npm publish` on every push to its default branch, from the
|
|
@@ -311,6 +325,18 @@ preset only fills in the command box; the saved command is what runs.
|
|
|
311
325
|
isn't on your PATH). They just open the repo. Nothing comes back for review unless
|
|
312
326
|
you run an agent there yourself, and Neovim needs a terminal to open in.
|
|
313
327
|
|
|
328
|
+
**Connectors.** The connectors you've linked to Claude (claude.ai's Google Drive,
|
|
329
|
+
Gmail, Notion…, and servers added with `claude mcp add`) are only usable by an
|
|
330
|
+
unattended run when `--allowedTools` names them, so a run asked to check your
|
|
331
|
+
Drive or mail used to be refused. Symbiot now adds each one's rule
|
|
332
|
+
(`mcp__claude_ai_Google_Drive` covers `mcp__claude_ai_Google_Drive__search_files`
|
|
333
|
+
and the rest of its tools) to a Claude command on every run. It reads them from
|
|
334
|
+
`~/.claude.json`, so linking one takes effect on the next run, and your saved
|
|
335
|
+
command stays as you typed it. A connector still waiting to be authorized at
|
|
336
|
+
claude.ai isn't added. Settings → Handoff lists them, and says so when your
|
|
337
|
+
command isn't Claude and its runs can't use them. Claude in Orca's tab asks you
|
|
338
|
+
before using one.
|
|
339
|
+
|
|
314
340
|
Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
|
|
315
341
|
**Agents** tab. The command is saved as `agentCmd` in
|
|
316
342
|
`~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
|
|
@@ -365,6 +391,16 @@ keep going with everything that doesn't depend on an answer, and to ask instead
|
|
|
365
391
|
doing anything destructive. Both files live in `.symbiot/`, so they're never
|
|
366
392
|
committed.
|
|
367
393
|
|
|
394
|
+
**A run that stopped on questions isn't repeated for nothing.** When a run ends
|
|
395
|
+
having asked questions you haven't answered, with tasks still unticked, Symbiot
|
|
396
|
+
keeps what it ran with in `.symbiot/blocked.json`. The next send to that repo
|
|
397
|
+
starts no agent until something it could act on changes: `.env` (or another
|
|
398
|
+
`.env.*` file there), `ANSWERS.md`, the agent command (a command you allowed, a
|
|
399
|
+
connector), or a task it didn't have. Otherwise the run would only ask the same
|
|
400
|
+
questions again. The send says so, with **Start it anyway** for when what changed
|
|
401
|
+
is somewhere else (`symbiot push --open --force` from the terminal). **Draft a
|
|
402
|
+
reply** always starts its run.
|
|
403
|
+
|
|
368
404
|
## What's out of sync? `symbiot drift`
|
|
369
405
|
|
|
370
406
|
```bash
|
|
@@ -589,7 +625,13 @@ the window, so a map only has the part that fits: when there's more, the screen
|
|
|
589
625
|
says so and **Scroll down** (and **Scroll up**) scrolls the page in the hidden
|
|
590
626
|
browser and maps what's in the window then, as a new screen. It scrolls what your
|
|
591
627
|
mouse wheel would: the page, or the part of it that scrolls on its own (Gmail's
|
|
592
|
-
list of mail). Scrolling only looks, so it never asks first. **
|
|
628
|
+
list of mail). Scrolling only looks, so it never asks first. Or **Whole page**
|
|
629
|
+
maps all of it at once: one tall screenshot (up to 16,000 pixels) with every
|
|
630
|
+
button, link and field on it marked where it is on the page. It's for a page that
|
|
631
|
+
scrolls as a whole; a list that scrolls inside the page, like Gmail's, still takes
|
|
632
|
+
Scroll down. The hidden browser scrolls down a window at a time and puts the
|
|
633
|
+
screenshots together, so a menu bar that stays at the top shows once, at the top,
|
|
634
|
+
and a sticky side menu shows in the first window only. **Press** on a region clicks it
|
|
593
635
|
in that hidden browser and maps the page it leads to as a new screen, so map,
|
|
594
636
|
press, map is how an agent finds its way around a site. **Type** on a field types
|
|
595
637
|
your text into it there (replacing what was in it) and, with **Type, then Enter**
|
|
@@ -621,6 +663,7 @@ symbiot screens type <id> "Search mail" "invoice" --enter --yes # type into a
|
|
|
621
663
|
symbiot screens type <id> To "sam@example.com" --yes # without --enter: the text stays in the field...
|
|
622
664
|
symbiot screens press <new id> Send --yes # ...for a press on the screen that just printed
|
|
623
665
|
symbiot screens scroll <id> # "more": "below"? scroll down and map the next part (or up, top, bottom)
|
|
666
|
+
symbiot screens whole <id> # all of that page in one tall screen (or: symbiot screens map <site> --whole)
|
|
624
667
|
symbiot screens show <id> # a saved screen's blueprint
|
|
625
668
|
symbiot screens signin gmail # sign in once, in Symbiot's browser window
|
|
626
669
|
symbiot screens # list them
|
|
@@ -651,8 +694,10 @@ Your coding agent reads it too, so it can act on it with `symbiot screens`:
|
|
|
651
694
|
symbiot watch add <screen id> --every 15 # watch a mapped page (or: symbiot watch add gmail)
|
|
652
695
|
symbiot watch add github # your GitHub notifications (see below)
|
|
653
696
|
symbiot watch new --hours 24 # what's new, newest first (JSON)
|
|
697
|
+
symbiot watch board # the Dashboard's cards (JSON; .total is the count, for a status bar)
|
|
698
|
+
symbiot watch seen <watch id> # set a card back to 0, like its Seen button
|
|
654
699
|
symbiot watch check # read them all now (JSON)
|
|
655
|
-
symbiot watch draft <id> # Draft a reply to a new email (see below)
|
|
700
|
+
symbiot watch draft <id> # Draft a reply to a new email or chat message (see below)
|
|
656
701
|
symbiot watch brief on # your AI says what needs you (off to stop)
|
|
657
702
|
symbiot watch # what you watch, and what's new
|
|
658
703
|
symbiot watch remove <id>
|
|
@@ -689,6 +734,15 @@ know, say) is answered there. The agent sees the email's text, so it reaches
|
|
|
689
734
|
whatever AI your agent uses. From a terminal, `symbiot watch new` marks the
|
|
690
735
|
emails `"mail": true`, and `symbiot watch draft <id>` does the same as the button.
|
|
691
736
|
|
|
737
|
+
A new WhatsApp message (a watched `web.whatsapp.com`) has **Draft a reply** too.
|
|
738
|
+
There, the agent opens the chat in the hidden browser and types the reply into
|
|
739
|
+
its message box, and leaves it there unsent. WhatsApp keeps it as that chat's
|
|
740
|
+
draft in Symbiot's browser: type `web.whatsapp.com` under Screens and click
|
|
741
|
+
**Sign in** to read it and send it. The same guard holds, plus one for chats: a
|
|
742
|
+
draft's run can't press Enter (in a chat, Enter sends), and in WhatsApp its line
|
|
743
|
+
breaks are typed as spaces. Add `web.whatsapp.com` under Trusted sites for it.
|
|
744
|
+
`symbiot watch new` marks those `"chat": true`.
|
|
745
|
+
|
|
692
746
|
**In Standup.** Standup ends with what's waiting on you since yesterday, counted
|
|
693
747
|
from what Watch found: `Waiting on you: 3 emails, 2 GitHub notifications`. Symbiot
|
|
694
748
|
counts them itself, so the numbers are right; the AI only sees them to know
|
package/agents.mjs
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync } from "node:fs";
|
|
8
|
-
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { loadConfig, saveConfig, loadTasks, sameTask, sh, hasCmd } from "./core.mjs";
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
9
|
+
import { loadConfig, saveConfig, loadTasks, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
|
|
10
10
|
|
|
11
11
|
const HANDOFFS = []; // live registry of agents Symbiot has handed work to
|
|
12
12
|
// ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
|
|
@@ -48,21 +48,50 @@ function grantRule(tool) {
|
|
|
48
48
|
t = t.replace(/[()"'`]/g, "").replace(/:\*$/, "").trim();
|
|
49
49
|
return t ? `Bash(${t}:*)` : "";
|
|
50
50
|
}
|
|
51
|
+
// Add a rule to a Claude command's --allowedTools (or start the flag), once.
|
|
52
|
+
function allowTool(cmd, t) {
|
|
53
|
+
if (!t || cmd.includes(`"${t}"`)) return cmd;
|
|
54
|
+
if (/--allowedTools\b/.test(cmd)) return cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
|
|
55
|
+
return cmd + ` --allowedTools "${t}"`;
|
|
56
|
+
}
|
|
57
|
+
const isClaudeCmd = (cmd) => /^\s*claude\b/.test(String(cmd || ""));
|
|
51
58
|
function grantAgent({ tool, dir } = {}) {
|
|
52
59
|
const cfg = loadConfig(); let cmd = (cfg.agentCmd || CLAUDE_CMD).trim();
|
|
53
|
-
if (
|
|
54
|
-
if (tool)
|
|
55
|
-
const t = grantRule(tool);
|
|
56
|
-
if (t && !cmd.includes(`"${t}"`)) {
|
|
57
|
-
if (/--allowedTools\b/.test(cmd)) cmd = cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
|
|
58
|
-
else cmd += ` --allowedTools "${t}"`;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
60
|
+
if (!isClaudeCmd(cmd)) return { error: "Grants apply to the Claude agent command. Pick a Claude preset first, or edit the command directly." };
|
|
61
|
+
if (tool) cmd = allowTool(cmd, grantRule(tool));
|
|
61
62
|
if (dir) { const d = String(dir).trim(); if (d && !cmd.includes(`--add-dir "${d}"`)) cmd += ` --add-dir "${d}"`; }
|
|
62
63
|
cmd = cmd.replace(/\s+/g, " ").trim();
|
|
63
64
|
cfg.agentCmd = cmd; saveConfig(cfg);
|
|
64
65
|
return { ok: true, cmd };
|
|
65
66
|
}
|
|
67
|
+
// ---- connectors: what the user linked to Claude, for its runs -------------
|
|
68
|
+
// Connectors linked to Claude (claude.ai's Google Drive, Gmail, Notion…, and
|
|
69
|
+
// servers added with `claude mcp add`) are tools to an unattended run only when
|
|
70
|
+
// --allowedTools names them: `claude -p` can't ask, so a run told to check your
|
|
71
|
+
// Drive or mail was refused and said it had no access. From ~/.claude.json: the
|
|
72
|
+
// claude.ai connectors that have connected (claudeAiMcpEverConnected), the
|
|
73
|
+
// user's own servers and this folder's. One waiting to be authorized
|
|
74
|
+
// (mcpNeedsAuthNoticed) is listed but not ready. Its rule, mcp__<name>, allows
|
|
75
|
+
// all its tools; Claude names them with anything but letters, digits, _ and -
|
|
76
|
+
// made _ ("claude.ai Google Drive" -> mcp__claude_ai_Google_Drive__search_files).
|
|
77
|
+
function claudeConnectors(dir = "", file = join(homedir(), ".claude.json")) {
|
|
78
|
+
let j = {}; try { j = JSON.parse(readFileSync(file, "utf8")) || {}; } catch {}
|
|
79
|
+
const names = (x) => Array.isArray(x) ? x.filter((s) => typeof s === "string" && s) : [];
|
|
80
|
+
const servers = (o) => o && typeof o === "object" && !Array.isArray(o) ? Object.keys(o) : [];
|
|
81
|
+
const proj = dir && j.projects && typeof j.projects === "object" ? j.projects[dir] : null;
|
|
82
|
+
const needsAuth = new Set(names(j.mcpNeedsAuthNoticed));
|
|
83
|
+
return [...new Set([...names(j.claudeAiMcpEverConnected), ...servers(j.mcpServers), ...servers(proj && proj.mcpServers)])]
|
|
84
|
+
.map((name) => ({ name, rule: "mcp__" + name.replace(/[^A-Za-z0-9_-]/g, "_"), ready: !needsAuth.has(name) }));
|
|
85
|
+
}
|
|
86
|
+
// A Claude command with the ready connectors' rules added, for this run only:
|
|
87
|
+
// the saved command stays as typed, so linking or unlinking one takes effect on
|
|
88
|
+
// the next run. Any other agent's command runs as-is (Settings says so).
|
|
89
|
+
function withConnectors(tmpl, dir, file) {
|
|
90
|
+
if (!isClaudeCmd(tmpl)) return tmpl;
|
|
91
|
+
return claudeConnectors(dir, file).filter((c) => c.ready).reduce((cmd, c) => allowTool(cmd, c.rule), tmpl).replace(/\s+$/, "");
|
|
92
|
+
}
|
|
93
|
+
// For Settings → Handoff: the connectors, and whether this command's runs get them.
|
|
94
|
+
function connectorsInfo(file) { return { claude: isClaudeCmd(handoffCmd()), list: claudeConnectors("", file).map(({ name, ready }) => ({ name, ready })) }; }
|
|
66
95
|
const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
|
|
67
96
|
// One agent per folder: two identical runs once started on the same repo 6s
|
|
68
97
|
// apart and raced each other. The registry catches a second click in this
|
|
@@ -74,15 +103,21 @@ const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPa
|
|
|
74
103
|
// A folder's .symbiot/handoff.json ({ name, env }) names its job and joins its
|
|
75
104
|
// environment, on every run there, a rerun with answers too: a draft reply's
|
|
76
105
|
// folder (watch.mjs) carries SYMBIOT_DRAFT, which refuses a press on Send.
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
// A folder whose last run stopped on questions, with nothing changed since
|
|
107
|
+
// that could answer them, gets no run: { blocked, questions, note }
|
|
108
|
+
// (blockedAgain). force starts one anyway (something changed elsewhere).
|
|
109
|
+
function runHandoff(repoPath, { force = false } = {}) {
|
|
110
|
+
let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
|
|
79
111
|
let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
|
|
80
112
|
const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
|
|
81
113
|
releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
|
|
114
|
+
tmpl = withConnectors(tmpl, repoPath);
|
|
115
|
+
const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
|
|
82
116
|
const lock = join(repoPath, ".symbiot", LOCK);
|
|
83
117
|
const env = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null;
|
|
84
|
-
const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, () => {
|
|
118
|
+
const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, (code) => {
|
|
85
119
|
try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
|
|
120
|
+
noteBlocked(repoPath, tmpl, e.startedAt, code);
|
|
86
121
|
startHeldTasks(repoPath); // tasks sent while it ran land now; start on them as that Send would have
|
|
87
122
|
}, env);
|
|
88
123
|
if (!e) return null;
|
|
@@ -90,6 +125,38 @@ function runHandoff(repoPath) {
|
|
|
90
125
|
if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt, owner: process.pid })); } catch {}
|
|
91
126
|
return e;
|
|
92
127
|
}
|
|
128
|
+
// ---- a run that would only report the same blockers again -------------------
|
|
129
|
+
// A run that ends having asked questions (QUESTIONS.md written during it, some
|
|
130
|
+
// still unanswered) with tasks left unticked is blocked on the user: noteBlocked
|
|
131
|
+
// keeps what it ran with in .symbiot/blocked.json. Another run on the same
|
|
132
|
+
// inputs could only say the same things again, so blockedAgain refuses one
|
|
133
|
+
// until something it could act on changes: .env (the values it asked for),
|
|
134
|
+
// ANSWERS.md, the agent command (a grant, a connector) or a task it didn't have.
|
|
135
|
+
const BLOCKED = "blocked.json";
|
|
136
|
+
const digest = (s) => createHash("sha1").update(String(s)).digest("hex").slice(0, 16);
|
|
137
|
+
const openTasks = (path) => readSymbiot(path, "TASKS.md").split("\n").filter((l) => /^\s*-\s*\[ \]/.test(l)).map((l) => taskWords(l.replace(/^\s*-\s*\[ \]\s*/, ""))).filter(Boolean);
|
|
138
|
+
function runInputs(path, cmd) {
|
|
139
|
+
let files = [], env = ""; try { files = readdirSync(path).filter((f) => /^\.env(\..+)?$/.test(f) && !/\.(example|sample|template)$/.test(f)).sort(); } catch {}
|
|
140
|
+
for (const f of files) { try { env += f + "\0" + readFileSync(join(path, f), "utf8") + "\0"; } catch {} }
|
|
141
|
+
return { env: digest(env), answers: digest(readSymbiot(path, "ANSWERS.md")), cmd: digest(cmd), open: openTasks(path) };
|
|
142
|
+
}
|
|
143
|
+
function noteBlocked(path, cmd, startedAt, code) {
|
|
144
|
+
const f = join(path, ".symbiot", BLOCKED);
|
|
145
|
+
let asked = false; try { asked = statSync(join(path, ".symbiot", "QUESTIONS.md")).mtimeMs >= startedAt; } catch {}
|
|
146
|
+
const questions = asked ? agentQuestions(path, "").questions.length : 0, inputs = runInputs(path, cmd);
|
|
147
|
+
try {
|
|
148
|
+
if (code === 0 && questions && inputs.open.length) writeFileSync(f, JSON.stringify({ at: Date.now(), questions, ...inputs }));
|
|
149
|
+
else unlinkSync(f);
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
function blockedAgain(path, cmd) {
|
|
153
|
+
let b = null; try { b = JSON.parse(readSymbiot(path, BLOCKED)); } catch {}
|
|
154
|
+
if (!b || !Array.isArray(b.open)) return null;
|
|
155
|
+
const now = runInputs(path, cmd), was = new Set(b.open);
|
|
156
|
+
if (now.env !== b.env || now.answers !== b.answers || now.cmd !== b.cmd || !now.open.length || now.open.some((t) => !was.has(t))) return null;
|
|
157
|
+
const n = b.questions || 0, them = n === 1 ? "it" : "them";
|
|
158
|
+
return { blocked: true, questions: n, note: `Not started: the last run here stopped on ${n} question${n === 1 ? "" : "s"}, and .env and ANSWERS.md haven't changed since, so another run would only ask again. Answer ${them} or change .env, then send again.` };
|
|
159
|
+
}
|
|
93
160
|
const LOCK = "agent.pid";
|
|
94
161
|
const LOCK_MAX_AGE = 12 * 3600 * 1000; // older than this, the pid has probably been reused
|
|
95
162
|
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (err) { return !!err && err.code === "EPERM"; } };
|
|
@@ -132,7 +199,7 @@ function releaseHeldTasks(path) {
|
|
|
132
199
|
// once, so this can't loop. Returns the new job, or null.
|
|
133
200
|
function startHeldTasks(path) {
|
|
134
201
|
if (!releaseHeldTasks(path) || !/^\s*-\s*\[ \]/m.test(readSymbiot(path, "TASKS.md"))) return null;
|
|
135
|
-
const n = runHandoff(path); if (!n || n.busy) return null;
|
|
202
|
+
const n = runHandoff(path); if (!n || n.busy || n.blocked) return null;
|
|
136
203
|
n.fromHeld = true; return n;
|
|
137
204
|
}
|
|
138
205
|
// Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
|
|
@@ -348,7 +415,7 @@ function answerQuestions(path, answers, opts = {}) {
|
|
|
348
415
|
writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
|
|
349
416
|
} catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
|
|
350
417
|
const out = { ok: true, saved: rows.length };
|
|
351
|
-
if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
|
|
418
|
+
if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e && e.blocked) out.note = "Answers saved. " + e.note; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
|
|
352
419
|
return out;
|
|
353
420
|
}
|
|
354
421
|
// The open tasks' titles in a held brief (TASKS.next.md), or null if nothing is held.
|
|
@@ -367,4 +434,4 @@ function agentsList() {
|
|
|
367
434
|
});
|
|
368
435
|
}
|
|
369
436
|
|
|
370
|
-
export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
|
|
437
|
+
export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
|
package/desktop.mjs
CHANGED
|
@@ -57,18 +57,20 @@ function weeklyState() { return { ...weeklyCfg(), latest: latestWeek() }; }
|
|
|
57
57
|
// Write the week with `produce` (index.mjs), save it and notify. `last` is set
|
|
58
58
|
// first, so a slow or failing run is never retried every minute. notify: false
|
|
59
59
|
// skips the notification, for a caller that tells you itself (the saved file
|
|
60
|
-
// and what's returned are the same either way)
|
|
60
|
+
// and what's returned are the same either way): the Week tab's button does, so
|
|
61
|
+
// a week written there is saved too. What's returned is produce's answer (text,
|
|
62
|
+
// footer), plus the file it was saved to, or an error.
|
|
61
63
|
async function runWeekly(produce, { notify: on = true } = {}) {
|
|
62
64
|
const notify = (body) => on && desktopNotify("Symbiot", body);
|
|
63
65
|
const cfg = loadConfig(); cfg.weekly = { ...weeklyCfg(cfg), last: Date.now() }; saveConfig(cfg);
|
|
64
66
|
const r = await produce("week");
|
|
65
67
|
if (r.error === "not-connected") { notify("Time for your weekly update. Connect an AI in Symbiot's Settings and it writes it for you."); return { error: r.error }; }
|
|
66
|
-
if (/^\(?couldn't reach the model/i.test(String(r.text || ""))) { notify("Couldn't write your week: the model didn't answer. Try the Week tab."); return { error: r.text }; }
|
|
68
|
+
if (/^\(?couldn't reach the model/i.test(String(r.text || ""))) { notify("Couldn't write your week: the model didn't answer. Try the Week tab."); return { ...r, error: r.text }; }
|
|
67
69
|
const file = join(WEEKS_DIR, localDay() + ".md");
|
|
68
70
|
try { mkdirSync(WEEKS_DIR, { recursive: true }); writeFileSync(file, String(r.text || "").trim() + (r.footer ? FOOT + r.footer : "") + "\n"); }
|
|
69
|
-
catch (e) { return { error: "Couldn't save the write-up: " + ((e && e.message) || e) }; }
|
|
71
|
+
catch (e) { return { ...r, error: "Couldn't save the write-up: " + ((e && e.message) || e) }; }
|
|
70
72
|
notify("Your week is written. Open the Week tab in Symbiot, or " + file);
|
|
71
|
-
return { ok: true, file
|
|
73
|
+
return { ...r, ok: true, file };
|
|
72
74
|
}
|
|
73
75
|
// Checks once a minute while the app runs. Returns a stop function.
|
|
74
76
|
function startWeekly(produce) {
|
package/headless.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// site. On a site you trust (Settings), that goes ahead without asking. In the
|
|
8
8
|
// app the browser stays open for a few minutes after each action, so the next
|
|
9
9
|
// one carries on from the page as it is (type into a field, then press Send).
|
|
10
|
+
// A map has what fits in the window: Scroll down maps the next part, and Whole
|
|
11
|
+
// page maps all of a page that scrolls as a whole in one tall screenshot.
|
|
10
12
|
// readPage reads a page without saving a screen, for Watch (watch.mjs).
|
|
11
13
|
//
|
|
12
14
|
// It drives Chrome / Chromium / Edge / Brave over the DevTools protocol on a pipe
|
|
@@ -19,7 +21,7 @@ import { spawn } from "node:child_process";
|
|
|
19
21
|
import { join } from "node:path";
|
|
20
22
|
import { mkdirSync, existsSync } from "node:fs";
|
|
21
23
|
import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
|
|
22
|
-
import { loadScreens, addPageScreen, center } from "./screens.mjs";
|
|
24
|
+
import { loadScreens, addPageScreen, center, stitchPng } from "./screens.mjs";
|
|
23
25
|
|
|
24
26
|
const PROFILE = join(CONFIG_DIR, "browser");
|
|
25
27
|
const VIEW = { w: 1280, h: 800 }; // the page's size: a laptop-sized window
|
|
@@ -207,10 +209,14 @@ const SCROLLER = `function scroller() {
|
|
|
207
209
|
// link, a part of the same kind is the same thing, so only the outer one counts.
|
|
208
210
|
// And how far down it's scrolled: { y, max, selector? } (no selector: the page
|
|
209
211
|
// itself), when there's more than the window shows.
|
|
210
|
-
|
|
212
|
+
// whole: called again a window further down each time (snapshotWhole), it adds
|
|
213
|
+
// what's new on screen to what it found before (kept in the page) and gives all
|
|
214
|
+
// of it, each where it is on the page, not the window.
|
|
215
|
+
const collectJs = (whole) => `(() => { ${SCROLLER}
|
|
211
216
|
const SEL = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[contenteditable=""],[contenteditable=true],[onclick],' +
|
|
212
217
|
['button','link','tab','menuitem','menuitemcheckbox','menuitemradio','option','checkbox','radio','switch','combobox','textbox','searchbox','row','treeitem'].map((r) => '[role=' + r + ']').join(',');
|
|
213
|
-
const W = innerWidth, H = innerHeight,
|
|
218
|
+
const W = innerWidth, H = innerHeight, X = ${whole ? "scrollX" : "0"}, Y = ${whole ? "scrollY" : "0"};
|
|
219
|
+
const kept = ${whole ? "(window.__symbiotWhole = window.__symbiotWhole || { out: [], kinds: new Map() })" : "{ out: [], kinds: new Map() }"}, out = kept.out, kinds = kept.kinds;
|
|
214
220
|
const clean = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
|
|
215
221
|
const kindOf = (e) => {
|
|
216
222
|
const t = e.tagName.toLowerCase(), r = (e.getAttribute('role') || '').toLowerCase(), ty = (e.getAttribute('type') || 'text').toLowerCase();
|
|
@@ -236,6 +242,7 @@ const COLLECT = `(() => { ${SCROLLER}
|
|
|
236
242
|
const img = e.querySelector('img[alt],svg title,[aria-label]');
|
|
237
243
|
return img ? clean(img.getAttribute('alt') || img.getAttribute('aria-label') || img.textContent) : '';
|
|
238
244
|
};
|
|
245
|
+
const stuck = (e) => { for (let n = e; n && n !== document.body; n = n.parentElement) if (/^(fixed|sticky)$/.test(getComputedStyle(n).position)) return true; return false; };
|
|
239
246
|
const unique = (sel) => { try { return document.querySelectorAll(sel).length === 1; } catch (x) { return false; } };
|
|
240
247
|
const cssPath = (e) => {
|
|
241
248
|
const parts = [];
|
|
@@ -250,7 +257,7 @@ const COLLECT = `(() => { ${SCROLLER}
|
|
|
250
257
|
};
|
|
251
258
|
for (const e of document.querySelectorAll(SEL)) {
|
|
252
259
|
if (out.length >= 200) break;
|
|
253
|
-
if (e.disabled || e.closest('[aria-hidden=true],[inert]')) continue;
|
|
260
|
+
if (kinds.has(e) || e.disabled || e.closest('[aria-hidden=true],[inert]')) continue;
|
|
254
261
|
const kind = kindOf(e), up = e.parentElement && e.parentElement.closest(SEL);
|
|
255
262
|
if (up && kinds.get(up) === kind && kind !== 'field') continue;
|
|
256
263
|
const b = e.getBoundingClientRect();
|
|
@@ -261,9 +268,11 @@ const COLLECT = `(() => { ${SCROLLER}
|
|
|
261
268
|
// on top: what's at its centre is it, or inside it (not a banner over it)
|
|
262
269
|
const top = document.elementFromPoint((x + x2) / 2, (y + y2) / 2);
|
|
263
270
|
if (!top || !(top === e || e.contains(top) || top.contains(e))) continue;
|
|
271
|
+
${whole ? "if (y >= H - 200 && stuck(e)) continue; // stuck to the window's bottom: left out of the whole page's picture" : ""}
|
|
264
272
|
kinds.set(e, kind);
|
|
265
273
|
const full = labelOf(e);
|
|
266
|
-
const
|
|
274
|
+
const box = ${whole ? "{ x: Math.max(0, b.left + X), y: Math.max(0, b.top + Y), w: b.width, h: b.height }" : "{ x, y, w: x2 - x, h: y2 - y }"};
|
|
275
|
+
const r = { label: full.slice(0, 80) || kind, kind, x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.w), h: Math.round(box.h), selector: cssPath(e) };
|
|
267
276
|
if (full.length > 80) r.text = full.slice(0, 400); // all of a long one (an inbox row), for Watch
|
|
268
277
|
if (e.href && /^https?:/.test(e.href)) r.href = String(e.href).slice(0, 500);
|
|
269
278
|
out.push(r);
|
|
@@ -272,10 +281,11 @@ const COLLECT = `(() => { ${SCROLLER}
|
|
|
272
281
|
const scroll = max > 0 ? { y: Math.round(sc.scrollTop), max, ...(sc === (document.scrollingElement || document.documentElement) ? {} : { selector: cssPath(sc) }) } : null;
|
|
273
282
|
return { url: location.href, title: document.title, items: out, ...(scroll ? { scroll } : {}) };
|
|
274
283
|
})()`;
|
|
284
|
+
const COLLECT = collectJs(false), COLLECT_WHOLE = collectJs(true);
|
|
275
285
|
|
|
276
286
|
// What's on the page now: { url, title, items }.
|
|
277
|
-
async function collect(page) {
|
|
278
|
-
const { result, exceptionDetails } = await page.send("Runtime.evaluate", { expression
|
|
287
|
+
async function collect(page, expression = COLLECT) {
|
|
288
|
+
const { result, exceptionDetails } = await page.send("Runtime.evaluate", { expression, returnByValue: true });
|
|
279
289
|
if (exceptionDetails) throw new Error("Couldn't read the page: " + ((exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text));
|
|
280
290
|
return (result && result.value) || { url: "", title: "", items: [] };
|
|
281
291
|
}
|
|
@@ -285,22 +295,106 @@ function signInPage(url) {
|
|
|
285
295
|
return /(^|\.)(accounts\.google|login\.(microsoftonline|live)|signin\.aws|auth0|okta)\./i.test(host + ".") || /\/(log-?in|sign-?in|auth|sso)\b/i.test(String(url || ""));
|
|
286
296
|
}
|
|
287
297
|
|
|
298
|
+
// A screenshot and what's on it, saved as a screen named `name`, else for the page.
|
|
299
|
+
function saveScreen(name, data, info, suffix = "") {
|
|
300
|
+
const host = hostOf(info.url), base = String(name || "").trim() || info.title || host || "Page";
|
|
301
|
+
const s = addPageScreen(suffix ? base.slice(0, 80 - suffix.length) + suffix : base, Buffer.isBuffer(data) ? data : Buffer.from(data, "base64"), info, info.items);
|
|
302
|
+
if (s.error) return s;
|
|
303
|
+
// a sign-in page instead of the site: say how to get past it once
|
|
304
|
+
return signInPage(info.url) ? { ...s, note: `This looks like a sign-in page (${host}). Click Sign in, sign in once in the window that opens, close it, then map again: the hidden browser keeps that sign-in.` } : s;
|
|
305
|
+
}
|
|
288
306
|
// The page as it is now, saved as a screen with its regions.
|
|
289
307
|
async function snapshot(page, name) {
|
|
290
308
|
const info = await collect(page);
|
|
291
309
|
const { data } = await page.send("Page.captureScreenshot", { format: "png" });
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
310
|
+
return saveScreen(name, data, info);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The whole page at once, in one screenshot as tall as the page (up to MAX_TALL),
|
|
314
|
+
// with every button, link and field on it as a region, where it is on the page.
|
|
315
|
+
// The window stays the laptop size (so a part as tall as the window stays that
|
|
316
|
+
// tall): it scrolls down a window at a time, finds what's on each part and takes
|
|
317
|
+
// its screenshot, and the screenshots are put together (screens.mjs stitchPng).
|
|
318
|
+
// What's fixed or sticky (a menu bar, a cookie banner) is hidden once it's in one,
|
|
319
|
+
// so it shows once, where it was, not on every part. The parts overlap by
|
|
320
|
+
// OVERLAP pixels, and the later one's are kept, so what stuck to the bottom of the
|
|
321
|
+
// window is left out (and not marked: collectJs). Only for a page that scrolls
|
|
322
|
+
// as a whole: a list that scrolls inside the page (Gmail's) wouldn't show more, so
|
|
323
|
+
// that gives { error, inside } and is left to Scroll down.
|
|
324
|
+
const MAX_TALL = 16000, OVERLAP = 200; // pixels
|
|
325
|
+
const PAGE_HEIGHT = `(() => { ${SCROLLER}
|
|
326
|
+
const d = document.scrollingElement || document.documentElement, sc = scroller();
|
|
327
|
+
return { h: Math.max(d.scrollHeight, document.body ? document.body.scrollHeight : 0), inside: !!sc && sc !== d };
|
|
328
|
+
})()`;
|
|
329
|
+
const HIDE_STUCK = `(() => { const H = innerHeight;
|
|
330
|
+
if (!document.getElementById('symbiot-hide')) { const st = document.createElement('style'); st.id = 'symbiot-hide'; st.textContent = '[data-symbiot-hide]{visibility:hidden!important}'; document.documentElement.appendChild(st); }
|
|
331
|
+
for (const e of document.querySelectorAll('body *')) {
|
|
332
|
+
const p = getComputedStyle(e).position; if (p !== 'fixed' && p !== 'sticky') continue;
|
|
333
|
+
const b = e.getBoundingClientRect(); if (b.bottom > 0 && b.top < H) e.setAttribute('data-symbiot-hide', '');
|
|
334
|
+
}
|
|
335
|
+
})()`;
|
|
336
|
+
const UNHIDE = `document.querySelectorAll('[data-symbiot-hide]').forEach((e) => e.removeAttribute('data-symbiot-hide')); const st = document.getElementById('symbiot-hide'); if (st) st.remove(); window.__symbiotWhole = null; scrollTo({ top: 0, behavior: 'instant' });`;
|
|
337
|
+
async function evaluate(page, expression) {
|
|
338
|
+
const { result, exceptionDetails } = await page.send("Runtime.evaluate", { expression, returnByValue: true });
|
|
339
|
+
if (exceptionDetails) throw new Error("Couldn't read the page: " + ((exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text));
|
|
340
|
+
return result && result.value;
|
|
341
|
+
}
|
|
342
|
+
async function snapshotWhole(page, name) {
|
|
343
|
+
const first = await evaluate(page, PAGE_HEIGHT);
|
|
344
|
+
if (first.inside) return { error: "This page doesn't scroll as a whole: a list inside it does (like Gmail's mail), and a tall screenshot wouldn't show more of that. Use Scroll down to map the next part of the list.", inside: true };
|
|
345
|
+
if (first.h <= VIEW.h + 1) { const s = await snapshot(page, name); return s.error || s.note ? s : { ...s, note: "It all fits in the window, so this map is the whole page." }; }
|
|
346
|
+
// a window at a time, overlapping a little; the page can load more as it goes (up to MAX_TALL)
|
|
347
|
+
let info = null, h = first.h, end = 0; const shots = [];
|
|
348
|
+
await evaluate(page, "window.__symbiotWhole = null");
|
|
349
|
+
try {
|
|
350
|
+
for (let y = 0, i = 0; i < 60; i++) {
|
|
351
|
+
await evaluate(page, `scrollTo({ top: ${y}, behavior: 'instant' })`); await sleep(150);
|
|
352
|
+
const at = Number(await evaluate(page, "scrollY")) || 0;
|
|
353
|
+
if (!info || info.items.length < 200) info = await collect(page, COLLECT_WHOLE);
|
|
354
|
+
const { data } = await page.send("Page.captureScreenshot", { format: "png" });
|
|
355
|
+
shots.push({ png: Buffer.from(data, "base64"), y: at }); end = at + VIEW.h;
|
|
356
|
+
await evaluate(page, HIDE_STUCK);
|
|
357
|
+
h = (await evaluate(page, PAGE_HEIGHT)).h;
|
|
358
|
+
if (end >= Math.min(MAX_TALL, h) || at < y) break; // the bottom (or it wouldn't scroll further)
|
|
359
|
+
y = Math.min(at + VIEW.h - OVERLAP, Math.min(MAX_TALL, h) - VIEW.h);
|
|
360
|
+
}
|
|
361
|
+
} finally { await evaluate(page, UNHIDE).catch(() => {}); }
|
|
362
|
+
const tall = Math.min(MAX_TALL, end), png = stitchPng(shots, tall);
|
|
363
|
+
if (!png) return { error: "Couldn't put the page's screenshots together." };
|
|
364
|
+
const items = info.items.filter((r) => r.y < tall);
|
|
365
|
+
const s = saveScreen(name, png, { url: info.url, title: info.title, full: true, items }, " (whole page)");
|
|
366
|
+
if (s.error || s.note) return s;
|
|
367
|
+
const note = [h > tall + 1 && `The page goes on past ${tall} pixels, so this map stops there.`, items.length >= 200 && "It marks the first 200 buttons, links and fields, from the top."].filter(Boolean).join(" ");
|
|
368
|
+
return note ? { ...s, note } : s;
|
|
297
369
|
}
|
|
298
370
|
|
|
299
371
|
// Map a site: open it in the hidden browser and save what's on it as a screen.
|
|
300
|
-
|
|
372
|
+
// whole: all of the page in one tall screen (snapshotWhole); a page whose list
|
|
373
|
+
// scrolls inside it gets the usual map, with a note that says why.
|
|
374
|
+
function mapPage(input, name, { whole = false } = {}) {
|
|
301
375
|
const url = siteUrl(input);
|
|
302
376
|
if (!url) return Promise.resolve({ error: "Give a site to map: a name (gmail), a host (github.com/pulls) or a web address." });
|
|
303
|
-
return oneAtATime(() => withPage(async (page) => {
|
|
377
|
+
return oneAtATime(() => withPage(async (page) => {
|
|
378
|
+
await open(page, url);
|
|
379
|
+
if (!whole) return snapshot(page, name);
|
|
380
|
+
const s = await snapshotWhole(page, name);
|
|
381
|
+
if (!s.inside) return s;
|
|
382
|
+
const one = await snapshot(page, name);
|
|
383
|
+
return one.error || one.note ? one : { ...one, note: s.error };
|
|
384
|
+
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The whole of a page you've mapped, as one tall screen (snapshotWhole). Only
|
|
388
|
+
// looks, so it never asks first.
|
|
389
|
+
function wholePage(id) {
|
|
390
|
+
const s = loadScreens().find((x) => x.id === id); if (!s) return Promise.resolve({ error: "not found" });
|
|
391
|
+
if (!s.page || !s.page.url) return Promise.resolve({ error: "Whole page works on a mapped page." });
|
|
392
|
+
return oneAtATime(() => withPage(async (page, b) => {
|
|
393
|
+
const here = await showScreen(page, b, s);
|
|
394
|
+
const next = await snapshotWhole(page, s.name.replace(/ ↓ \d+%$/, "").replace(/ \(whole page\)$/, ""));
|
|
395
|
+
if (next.error) { delete next.inside; return next; }
|
|
396
|
+
return { ...next, kept: here };
|
|
397
|
+
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
304
398
|
}
|
|
305
399
|
|
|
306
400
|
// Read a site as it is now, without saving a screen: { url, title, items, login }.
|
|
@@ -408,7 +502,11 @@ function actOnRegion(id, regionId, verb, confirmed, check, act) {
|
|
|
408
502
|
e.scrollIntoView({ block: 'center', inline: 'center' }); const b = e.getBoundingClientRect();
|
|
409
503
|
return b.width && b.height ? { x: b.left + b.width / 2, y: b.top + b.height / 2 } : null; })()`;
|
|
410
504
|
const { result } = await page.send("Runtime.evaluate", { expression: find, returnByValue: true });
|
|
411
|
-
const
|
|
505
|
+
const found = !!(result && result.value);
|
|
506
|
+
// not found by its selector: the same spot. On a whole-page screen that spot is
|
|
507
|
+
// on the page, not the window: scroll it into the window first.
|
|
508
|
+
let at = found ? result.value : center(r);
|
|
509
|
+
if (!found && s.page.full) { const y = await evaluate(page, `(scrollTo(0, ${at.y} - innerHeight / 2), scrollY)`); at = { x: at.x, y: at.y - (Number(y) || 0) }; }
|
|
412
510
|
const navigating = page.until("Page.frameStartedLoading", 1500);
|
|
413
511
|
const extra = await act(page, at, r);
|
|
414
512
|
if (await navigating) await page.until("Page.loadEventFired", 30000);
|
|
@@ -432,10 +530,15 @@ function pressRegion(id, regionId, { confirmed = false, noSend = false } = {}) {
|
|
|
432
530
|
// asked (which is how a search or a one-line form is sent). Without Enter, press
|
|
433
531
|
// the form's button next, on the screen this maps, while the browser is still
|
|
434
532
|
// open (the app's): what's typed is still there. Otherwise it's gone by then.
|
|
533
|
+
// noSend (a draft reply's run) refuses Enter, which sends in a chat, and in a
|
|
534
|
+
// WhatsApp chat types line breaks as spaces, in case a new line sends there too.
|
|
435
535
|
const MAX_TEXT = 2000;
|
|
436
|
-
|
|
536
|
+
const CHAT_HOSTS = new Set(["web.whatsapp.com"]);
|
|
537
|
+
function typeRegion(id, regionId, text, { enter = false, confirmed = false, noSend = false } = {}) {
|
|
437
538
|
text = String(text == null ? "" : text);
|
|
539
|
+
if (noSend) { const s = loadScreens().find((x) => x.id === id); if (s && s.page && CHAT_HOSTS.has(hostOf(s.page.url))) text = text.replace(/\s*[\r\n]+\s*/g, " ").trim(); }
|
|
438
540
|
return actOnRegion(id, regionId, "Type", confirmed, (r) => {
|
|
541
|
+
if (noSend && enter) return "Enter sends in a chat, and this run only drafts: type without --enter, and the reply stays unsent for the user to send.";
|
|
439
542
|
if (r.kind !== "field") return `"${r.label}" isn't a field (it's a ${r.kind || "region"}). Type works on a field; use Press for the rest.`;
|
|
440
543
|
if (!text && !enter) return "Give the text to type.";
|
|
441
544
|
if (text.length > MAX_TEXT) return `That's more than ${MAX_TEXT} characters.`;
|
|
@@ -463,4 +566,4 @@ async function signIn(input) {
|
|
|
463
566
|
return { ok: true, url };
|
|
464
567
|
}
|
|
465
568
|
|
|
466
|
-
export { siteUrl, browserArgs, mapPage, readPage, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
|
|
569
|
+
export { siteUrl, browserArgs, mapPage, wholePage, readPage, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
|