symbiot 0.41.7 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -11
- package/agents.mjs +108 -7
- package/headless.mjs +80 -17
- package/index.mjs +9 -5
- package/links.mjs +149 -0
- package/package.json +3 -2
- package/screens.mjs +28 -5
- package/server.mjs +14 -2
- package/tasks.mjs +1 -0
- package/ui.mjs +87 -15
- package/watch.mjs +66 -8
- package/writeups.mjs +19 -7
package/README.md
CHANGED
|
@@ -60,9 +60,13 @@ Tabs:
|
|
|
60
60
|
how many new since yesterday (or in the last 3 or 7 days), its latest brief, the
|
|
61
61
|
newest items, and **Open**, **Draft a reply** and **Check now** on them. **Seen**
|
|
62
62
|
on a card sets its count back to 0 (what it found stays under Watching, and the
|
|
63
|
-
other cards keep theirs).
|
|
63
|
+
other cards keep theirs). **💬** on a card opens a chat with your AI about what's
|
|
64
|
+
new there: what needs you, and what to say to whom. Agree it there, then **Draft
|
|
65
|
+
a reply** on one, and your agent gets that talk with the message, so it writes
|
|
66
|
+
what you agreed. The tab shows the count, so you see what's waiting from
|
|
64
67
|
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
|
|
68
|
+
the Map. `symbiot watch board` prints the same cards as JSON, and
|
|
69
|
+
`symbiot watch board --line` as one line for a status bar (`2 emails · 1 WhatsApp message`).
|
|
66
70
|
- **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
|
|
67
71
|
a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
|
|
68
72
|
- **Week / Standup / Todo** — the write-ups (these use your chosen AI).
|
|
@@ -81,7 +85,7 @@ Tabs:
|
|
|
81
85
|
did** — files changed and commits made, read from git, whichever agent it was.
|
|
82
86
|
When an agent leaves **questions, options or ideas** for you, they show up on its
|
|
83
87
|
block (see [Questions from your agent](#questions-from-your-agent)).
|
|
84
|
-
- **Settings** — your AI, the folders to scan, your agent command, the
|
|
88
|
+
- **Settings** — [Link your work](#link-your-work), your AI, the folders to scan, your agent command, the
|
|
85
89
|
[weekly write-up and start at login](#every-week-and-at-login), plus the
|
|
86
90
|
[experimental](#experimental) email, model recommendations and one-click local
|
|
87
91
|
model.
|
|
@@ -389,7 +393,16 @@ that starts with `[repo: <name>]`, to that repo's Tasks: an agent working on one
|
|
|
389
393
|
project can have ideas for another (Symbiot itself, say). The agent is told to
|
|
390
394
|
keep going with everything that doesn't depend on an answer, and to ask instead of
|
|
391
395
|
doing anything destructive. Both files live in `.symbiot/`, so they're never
|
|
392
|
-
committed.
|
|
396
|
+
committed. The questions stay on the Agents tab after Symbiot restarts (or when
|
|
397
|
+
`symbiot push --open` started the run): Symbiot notes each folder a run starts in
|
|
398
|
+
(`~/.config/symbiot/runs.json`) and lists the ones still waiting on you.
|
|
399
|
+
|
|
400
|
+
**A 👤 You answer is your step.** Picking an option that starts with **You** saves
|
|
401
|
+
the answer and reminds you the step is still yours to do. Your agent waits for it
|
|
402
|
+
instead of starting straight away and asking the same thing again. If the step
|
|
403
|
+
names a file in backticks (`` `.env` ``), the agent starts by itself once that
|
|
404
|
+
file changes, while the app runs. Otherwise **Start it now** on that folder's
|
|
405
|
+
block starts it once you've done the step.
|
|
393
406
|
|
|
394
407
|
**A run that stopped on questions isn't repeated for nothing.** When a run ends
|
|
395
408
|
having asked questions you haven't answered, with tasks still unticked, Symbiot
|
|
@@ -426,6 +439,40 @@ repo), keyed by repo path or folder name, that prints the deployed commit sha:
|
|
|
426
439
|
`{ "/path/to/repo": "ssh prod cat ~/app/.deployed-sha" }`. Also the **Drift** tab
|
|
427
440
|
in the app.
|
|
428
441
|
|
|
442
|
+
## Link your work
|
|
443
|
+
|
|
444
|
+
Settings → **Link your work** has one button per standard work site: Gmail,
|
|
445
|
+
Outlook, Google and Outlook Calendar, GitHub, GitLab, Slack, Microsoft Teams,
|
|
446
|
+
WhatsApp, Jira & Confluence, Linear, Asana, Trello, Google Drive, Notion,
|
|
447
|
+
HubSpot and Salesforce. One click:
|
|
448
|
+
|
|
449
|
+
1. opens the site in Symbiot's own browser, where you sign in the usual way (its
|
|
450
|
+
own login, SSO and 2FA; Symbiot never sees your password). Close the window
|
|
451
|
+
when you're signed in;
|
|
452
|
+
2. trusts the site, so [Screens](#screens-blueprints-for-screen-automation)' Press
|
|
453
|
+
and Type work there without asking;
|
|
454
|
+
3. watches its inbox or notifications, so what arrives shows on the **Dashboard**,
|
|
455
|
+
in Standup's "Waiting on you", and in **Week**. Someone who doesn't write code
|
|
456
|
+
gets a Week from their linked sites too.
|
|
457
|
+
|
|
458
|
+
Each button shows where it stands: grey (not linked), amber (sign in, or signed
|
|
459
|
+
out), green (linked), with ↻ to check now and × to unlink. Unlinking stops
|
|
460
|
+
watching it and stops trusting what the link trusted, never a site you trusted
|
|
461
|
+
yourself.
|
|
462
|
+
|
|
463
|
+
**For a whole company**, one `links.json` in Symbiot's config folder (or the file
|
|
464
|
+
`SYMBIOT_LINKS` names) gives everyone the same buttons. It adds your own sites and
|
|
465
|
+
hides the ones you don't use. An entry with a built-in's id replaces it:
|
|
466
|
+
|
|
467
|
+
```json
|
|
468
|
+
{ "links": [{ "id": "jira", "name": "Jira", "url": "https://acme.atlassian.net/jira/your-work", "watch": true },
|
|
469
|
+
{ "name": "Acme CRM", "url": "https://crm.acme.example/inbox", "hosts": ["sso.acme.example"], "watch": true }],
|
|
470
|
+
"hide": ["whatsapp", "trello"] }
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Linking stays in the app. Like trusted sites, there's no command an agent could
|
|
474
|
+
call to link a site itself.
|
|
475
|
+
|
|
429
476
|
## Connect it (once)
|
|
430
477
|
|
|
431
478
|
Pick the AI you want it to write with:
|
|
@@ -627,11 +674,14 @@ browser and maps what's in the window then, as a new screen. It scrolls what you
|
|
|
627
674
|
mouse wheel would: the page, or the part of it that scrolls on its own (Gmail's
|
|
628
675
|
list of mail). Scrolling only looks, so it never asks first. Or **Whole page**
|
|
629
676
|
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.
|
|
631
|
-
scrolls
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
677
|
+
button, link and field on it marked where it is on the page. The hidden browser
|
|
678
|
+
scrolls down a window at a time and puts the screenshots together, so a menu bar
|
|
679
|
+
that stays at the top shows once, at the top, and a sticky side menu shows in the
|
|
680
|
+
first window only. Where a list scrolls inside the page instead, like Gmail's
|
|
681
|
+
mail, Whole page opens that list out: it scrolls the list a part at a time and
|
|
682
|
+
puts the parts together, with the rest of the window (the search bar, Compose,
|
|
683
|
+
your folders) around it once, so a whole inbox page is one screen with every row
|
|
684
|
+
marked. **Press** on a region clicks it
|
|
635
685
|
in that hidden browser and maps the page it leads to as a new screen, so map,
|
|
636
686
|
press, map is how an agent finds its way around a site. **Type** on a field types
|
|
637
687
|
your text into it there (replacing what was in it) and, with **Type, then Enter**
|
|
@@ -695,6 +745,7 @@ symbiot watch add <screen id> --every 15 # watch a mapped page (or: symbiot wa
|
|
|
695
745
|
symbiot watch add github # your GitHub notifications (see below)
|
|
696
746
|
symbiot watch new --hours 24 # what's new, newest first (JSON)
|
|
697
747
|
symbiot watch board # the Dashboard's cards (JSON; .total is the count, for a status bar)
|
|
748
|
+
symbiot watch board --line # the same as one line: "2 emails · 1 WhatsApp message" (empty when nothing's new)
|
|
698
749
|
symbiot watch seen <watch id> # set a card back to 0, like its Seen button
|
|
699
750
|
symbiot watch check # read them all now (JSON)
|
|
700
751
|
symbiot watch draft <id> # Draft a reply to a new email or chat message (see below)
|
|
@@ -737,8 +788,9 @@ emails `"mail": true`, and `symbiot watch draft <id>` does the same as the butto
|
|
|
737
788
|
A new WhatsApp message (a watched `web.whatsapp.com`) has **Draft a reply** too.
|
|
738
789
|
There, the agent opens the chat in the hidden browser and types the reply into
|
|
739
790
|
its message box, and leaves it there unsent. WhatsApp keeps it as that chat's
|
|
740
|
-
draft in Symbiot's browser:
|
|
741
|
-
|
|
791
|
+
draft in Symbiot's browser: once the agent is done, **Open in WhatsApp** on the
|
|
792
|
+
message opens that browser at `web.whatsapp.com` as a window, to read the reply
|
|
793
|
+
and send it (close the window after, so Watch can read WhatsApp again). The same guard holds, plus one for chats: a
|
|
742
794
|
draft's run can't press Enter (in a chat, Enter sends), and in WhatsApp its line
|
|
743
795
|
breaks are typed as spaces. Add `web.whatsapp.com` under Trusted sites for it.
|
|
744
796
|
`symbiot watch new` marks those `"chat": true`.
|
package/agents.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// tab, and the questions an unattended agent leaves for the user.
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
7
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
8
8
|
import { randomBytes, createHash } from "node:crypto";
|
|
9
|
-
import { loadConfig, saveConfig, loadTasks, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
|
|
9
|
+
import { CONFIG_DIR, 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
|
|
@@ -112,6 +112,8 @@ function runHandoff(repoPath, { force = false } = {}) {
|
|
|
112
112
|
const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
|
|
113
113
|
releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
|
|
114
114
|
tmpl = withConnectors(tmpl, repoPath);
|
|
115
|
+
if (force) { try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} }
|
|
116
|
+
else { const w = waitingFor(repoPath); if (w) return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; }
|
|
115
117
|
const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
|
|
116
118
|
const lock = join(repoPath, ".symbiot", LOCK);
|
|
117
119
|
const env = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null;
|
|
@@ -123,8 +125,92 @@ function runHandoff(repoPath, { force = false } = {}) {
|
|
|
123
125
|
if (!e) return null;
|
|
124
126
|
e.handoff = true;
|
|
125
127
|
if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt, owner: process.pid })); } catch {}
|
|
128
|
+
noteRun(e);
|
|
126
129
|
return e;
|
|
127
130
|
}
|
|
131
|
+
// ---- runs from before the app started ---------------------------------------
|
|
132
|
+
// HANDOFFS lives in memory, so after a restart a run that stopped on questions
|
|
133
|
+
// was gone from the Agents tab, its questions with it: there was no answering
|
|
134
|
+
// them there, only Start it anyway or a change to .env. So each folder a run
|
|
135
|
+
// starts in is noted in ~/.config/symbiot/runs.json ({ path, name, startedAt },
|
|
136
|
+
// newest first, by whichever process started it: the app, or `symbiot push
|
|
137
|
+
// --open`), and the Agents tab lists the ones it isn't tracking itself while they
|
|
138
|
+
// have questions open, a step of yours to wait for, or a run still going.
|
|
139
|
+
const RUNS_FILE = join(CONFIG_DIR, "runs.json"), RUNS_MAX = 60;
|
|
140
|
+
function loadRuns() { try { const a = JSON.parse(readFileSync(RUNS_FILE, "utf8")); return Array.isArray(a) ? a.filter((r) => r && typeof r.path === "string" && r.path) : []; } catch { return []; } }
|
|
141
|
+
function noteRun(e) {
|
|
142
|
+
const list = [{ path: e.path, name: e.name, startedAt: e.startedAt }, ...loadRuns().filter((r) => r.path !== e.path)].slice(0, RUNS_MAX);
|
|
143
|
+
try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(RUNS_FILE, JSON.stringify(list, null, 2), { mode: 0o600 }); } catch {}
|
|
144
|
+
}
|
|
145
|
+
const knownRun = (path) => HANDOFFS.some((e) => e.path === path) || loadRuns().some((r) => r.path === path);
|
|
146
|
+
// The Agents tab's block for a run another process (or this app before a
|
|
147
|
+
// restart) started: as agentsList gives one, marked `earlier`. Its end is when
|
|
148
|
+
// its log last changed; its exit code isn't known.
|
|
149
|
+
function earlierRuns() {
|
|
150
|
+
const tracked = new Set(HANDOFFS.map((e) => e.path)), out = [];
|
|
151
|
+
for (const r of loadRuns()) {
|
|
152
|
+
if (tracked.has(r.path) || !existsSync(join(r.path, ".symbiot"))) continue;
|
|
153
|
+
const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path);
|
|
154
|
+
if (!busy && !ask.questions.length && !wait) continue;
|
|
155
|
+
const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
|
|
156
|
+
let tail = "", end = Date.now(); try { tail = readFileSync(log, "utf8").slice(-1200); if (!busy) end = statSync(log).mtimeMs; } catch {}
|
|
157
|
+
out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, fromHeld: false });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- a step that's yours: wait for it -----------------------------------------
|
|
163
|
+
// An answer that picks a "👤 You:" option leaves a step for the user to do (put a
|
|
164
|
+
// key in .env, allow a command in Settings). Starting the agent before it's done
|
|
165
|
+
// only gets the same questions back. So answerQuestions reminds them the step is
|
|
166
|
+
// theirs, and Send answers & continue waits instead of starting: .symbiot/
|
|
167
|
+
// waiting.json keeps the step and how each file it names (in backticks, like
|
|
168
|
+
// `.env`) was then. Once one of them changes, the next run goes ahead
|
|
169
|
+
// (startWaiting starts the one that was asked for); with no file to watch, it
|
|
170
|
+
// waits for Start it now. Start it anyway (force) always goes ahead.
|
|
171
|
+
const WAITING = "waiting.json";
|
|
172
|
+
const yourStep = (a) => /👤/.test(String(a || ""));
|
|
173
|
+
// The files a step names, in backticks: `.env`, `~/.termux/termux.properties`,
|
|
174
|
+
// `.claude/settings.json`. Not a command (`npm test`), a site (`web.whatsapp.com`,
|
|
175
|
+
// unless there's a file by that name) or a path that can't be one here.
|
|
176
|
+
function namedFiles(text, dir) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const m of String(text || "").matchAll(/`([^`\s]+)`/g)) {
|
|
179
|
+
const t = m[1].replace(/[.,:;]+$/, ""), first = t.replace(/^~?\//, "").split("/")[0];
|
|
180
|
+
if (/:\/\//.test(t) || !/[./]/.test(t) || /^[@\d]/.test(t) || /^\.{1,2}\/?$/.test(t)) continue;
|
|
181
|
+
const p = t.startsWith("~/") ? join(homedir(), t.slice(2)) : t.startsWith("/") ? t : join(dir, t);
|
|
182
|
+
const dotted = first.startsWith(".") && first !== "." && first !== "..";
|
|
183
|
+
const host = !dotted && /\./.test(first) && !t.startsWith("~/") && !t.startsWith("/");
|
|
184
|
+
if (existsSync(p) || (!host && (dotted || existsSync(dirname(p))))) out.push({ name: t, path: p });
|
|
185
|
+
}
|
|
186
|
+
return out.filter((f, i) => out.findIndex((g) => g.path === f.path) === i).slice(0, 5);
|
|
187
|
+
}
|
|
188
|
+
const fileSig = (p) => { try { const s = statSync(p); return s.mtimeMs + ":" + s.size; } catch { return "none"; } };
|
|
189
|
+
// What a folder is waiting on: { step, files: [names], at, rerun }, or null (and
|
|
190
|
+
// waiting.json goes) once a file it names has changed.
|
|
191
|
+
function waitingFor(path) {
|
|
192
|
+
let w = null; try { w = JSON.parse(readSymbiot(path, WAITING)); } catch {}
|
|
193
|
+
if (!w || typeof w.step !== "string") return null;
|
|
194
|
+
const files = Array.isArray(w.files) ? w.files : [];
|
|
195
|
+
if (files.some((f) => f && fileSig(f.path) !== f.sig)) { try { unlinkSync(join(path, ".symbiot", WAITING)); } catch {} return null; }
|
|
196
|
+
return { step: w.step, files: files.map((f) => f.name), at: w.at || 0, rerun: !!w.rerun };
|
|
197
|
+
}
|
|
198
|
+
function waitNote(w) {
|
|
199
|
+
const names = w.files.map((f) => "`" + f + "`").join(" or ");
|
|
200
|
+
return `Not started: your step comes first: ${w.step.replace(/^\s*👤\s*(You:)?\s*/, "")} ${names ? `Your agent starts once ${names} changes.` : "Once it's done, click Start it now (or Start it anyway)."}`;
|
|
201
|
+
}
|
|
202
|
+
// Runs that waited for a file of yours, and were asked for, start once it changes.
|
|
203
|
+
// The app calls this every so often. Returns the jobs started.
|
|
204
|
+
function startWaiting() {
|
|
205
|
+
const started = [];
|
|
206
|
+
for (const r of loadRuns()) {
|
|
207
|
+
let w = null; try { w = JSON.parse(readSymbiot(r.path, WAITING)); } catch {}
|
|
208
|
+
if (!w || !w.rerun || !Array.isArray(w.files) || !w.files.length || runningHandoff(r.path)) continue;
|
|
209
|
+
if (waitingFor(r.path)) continue; // nothing it names has changed yet
|
|
210
|
+
const e = runHandoff(r.path); if (e && !e.busy && !e.blocked) started.push(e);
|
|
211
|
+
}
|
|
212
|
+
return started;
|
|
213
|
+
}
|
|
128
214
|
// ---- a run that would only report the same blockers again -------------------
|
|
129
215
|
// A run that ends having asked questions (QUESTIONS.md written during it, some
|
|
130
216
|
// still unanswered) with tasks left unticked is blocked on the user: noteBlocked
|
|
@@ -400,9 +486,13 @@ function agentQuestions(path, repo) {
|
|
|
400
486
|
}
|
|
401
487
|
// Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
|
|
402
488
|
// agent (same saved command) so it carries on with them.
|
|
489
|
+
// An answer that picks a "👤 You:" option is a step for the user: they're told
|
|
490
|
+
// it's still theirs (`yours`), and the next run waits for it (waiting.json).
|
|
491
|
+
// A folder a run started in before a restart can be answered too (runs.json).
|
|
492
|
+
const stepText = (a) => String(a).split("🤖")[0].replace(/^\s*👤\s*(You:)?\s*/, "").replace(/\s*\(recommended\)\s*$/i, "").trim();
|
|
403
493
|
function answerQuestions(path, answers, opts = {}) {
|
|
404
494
|
path = String(path || "");
|
|
405
|
-
if (!path || !
|
|
495
|
+
if (!path || !knownRun(path)) return { error: "No agent has run in that folder." };
|
|
406
496
|
const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
|
|
407
497
|
const rows = (Array.isArray(answers) ? answers : [])
|
|
408
498
|
.map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, 2000) }))
|
|
@@ -415,6 +505,15 @@ function answerQuestions(path, answers, opts = {}) {
|
|
|
415
505
|
writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
|
|
416
506
|
} catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
|
|
417
507
|
const out = { ok: true, saved: rows.length };
|
|
508
|
+
const yours = rows.filter((x) => yourStep(x.a)).map((x) => stepText(x.a)).filter(Boolean);
|
|
509
|
+
if (yours.length) {
|
|
510
|
+
const step = yours.join(" "), files = namedFiles(rows.filter((x) => yourStep(x.a)).map((x) => x.a.split("🤖")[0]).join(" "), path);
|
|
511
|
+
try { writeFileSync(join(path, ".symbiot", WAITING), JSON.stringify({ step, files: files.map((f) => ({ ...f, sig: fileSig(f.path) })), at: Date.now(), rerun: !!opts.rerun })); } catch {}
|
|
512
|
+
const names = files.map((f) => "`" + f.name + "`").join(" or ");
|
|
513
|
+
out.yours = yours; if (files.length) out.waitFiles = files.map((f) => f.name);
|
|
514
|
+
out.note = "Answers saved. That step is still yours to do. " + (files.length ? `Your agent ${opts.rerun ? "starts by itself" : "can start"} once ${names} changes${opts.rerun ? " (while Symbiot runs)" : ""}.` : "Once it's done, click Start it now on this folder.");
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
418
517
|
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."; }
|
|
419
518
|
return out;
|
|
420
519
|
}
|
|
@@ -424,14 +523,16 @@ function heldTasks(path) {
|
|
|
424
523
|
return md ? md.split("\n").filter((l) => /^\s*-\s*\[ \]/.test(l)).map((l) => l.replace(/^\s*-\s*\[ \]\s*/, "").trim().slice(0, 200)) : null;
|
|
425
524
|
}
|
|
426
525
|
// The Agents tab: every tracked job, its log tail, what it changed, and — on the
|
|
427
|
-
// newest job per folder — the questions/ideas it left
|
|
526
|
+
// newest job per folder — the questions/ideas it left, any tasks held for it and
|
|
527
|
+
// a step of yours it waits on. Then runs from before the app started that still
|
|
528
|
+
// need you (earlierRuns).
|
|
428
529
|
function agentsList() {
|
|
429
530
|
const seen = new Set();
|
|
430
531
|
return HANDOFFS.map((e) => {
|
|
431
532
|
let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
|
|
432
533
|
const first = !seen.has(e.path); seen.add(e.path);
|
|
433
|
-
return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, fromHeld: !!e.fromHeld };
|
|
434
|
-
});
|
|
534
|
+
return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, fromHeld: !!e.fromHeld };
|
|
535
|
+
}).concat(earlierRuns());
|
|
435
536
|
}
|
|
436
537
|
|
|
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 };
|
|
538
|
+
export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
|
package/headless.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
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
10
|
// A map has what fits in the window: Scroll down maps the next part, and Whole
|
|
11
|
-
// page maps all of
|
|
11
|
+
// page maps all of it in one tall screenshot: the page, or, where a list scrolls
|
|
12
|
+
// inside it (Gmail's mail), that list opened out to its full length.
|
|
12
13
|
// readPage reads a page without saving a screen, for Watch (watch.mjs).
|
|
13
14
|
//
|
|
14
15
|
// It drives Chrome / Chromium / Edge / Brave over the DevTools protocol on a pipe
|
|
@@ -21,7 +22,7 @@ import { spawn } from "node:child_process";
|
|
|
21
22
|
import { join } from "node:path";
|
|
22
23
|
import { mkdirSync, existsSync } from "node:fs";
|
|
23
24
|
import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
|
|
24
|
-
import { loadScreens, addPageScreen, center, stitchPng } from "./screens.mjs";
|
|
25
|
+
import { loadScreens, addPageScreen, center, stitchPng, stitchListPng } from "./screens.mjs";
|
|
25
26
|
|
|
26
27
|
const PROFILE = join(CONFIG_DIR, "browser");
|
|
27
28
|
const VIEW = { w: 1280, h: 800 }; // the page's size: a laptop-sized window
|
|
@@ -212,11 +213,19 @@ const SCROLLER = `function scroller() {
|
|
|
212
213
|
// whole: called again a window further down each time (snapshotWhole), it adds
|
|
213
214
|
// what's new on screen to what it found before (kept in the page) and gives all
|
|
214
215
|
// of it, each where it is on the page, not the window.
|
|
215
|
-
|
|
216
|
+
// list: { selector, part, more } for a list that scrolls inside the page
|
|
217
|
+
// (snapshotList). part "list": what's in the list, called again as it scrolls
|
|
218
|
+
// and kept like whole's, each where it is with the list opened out (its y plus
|
|
219
|
+
// how far the list is scrolled), cut to the list's box. part "rest": the rest of
|
|
220
|
+
// the window, what's below the list moved down by `more`.
|
|
221
|
+
const collectJs = (whole, list = null) => `(() => { ${SCROLLER}
|
|
216
222
|
const SEL = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[contenteditable=""],[contenteditable=true],[onclick],' +
|
|
217
223
|
['button','link','tab','menuitem','menuitemcheckbox','menuitemradio','option','checkbox','radio','switch','combobox','textbox','searchbox','row','treeitem'].map((r) => '[role=' + r + ']').join(',');
|
|
218
224
|
const W = innerWidth, H = innerHeight, X = ${whole ? "scrollX" : "0"}, Y = ${whole ? "scrollY" : "0"};
|
|
219
|
-
const
|
|
225
|
+
const IN = ${JSON.stringify(list)}, LIST = IN ? document.querySelector(IN.selector) : null;
|
|
226
|
+
const LB = LIST && (() => { const b = LIST.getBoundingClientRect(); return { left: Math.max(0, b.left), top: Math.max(0, b.top), right: Math.min(W, b.right), bottom: Math.min(H, b.bottom) }; })();
|
|
227
|
+
if (IN && !LIST) return { url: location.href, title: document.title, items: [] };
|
|
228
|
+
const kept = ${whole || (list && list.part === "list") ? "(window.__symbiotWhole = window.__symbiotWhole || { out: [], kinds: new Map(), at: new Set() })" : "{ out: [], kinds: new Map(), at: new Set() }"}, out = kept.out, kinds = kept.kinds;
|
|
220
229
|
const clean = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
|
|
221
230
|
const kindOf = (e) => {
|
|
222
231
|
const t = e.tagName.toLowerCase(), r = (e.getAttribute('role') || '').toLowerCase(), ty = (e.getAttribute('type') || 'text').toLowerCase();
|
|
@@ -257,11 +266,18 @@ const collectJs = (whole) => `(() => { ${SCROLLER}
|
|
|
257
266
|
};
|
|
258
267
|
for (const e of document.querySelectorAll(SEL)) {
|
|
259
268
|
if (out.length >= 200) break;
|
|
260
|
-
|
|
269
|
+
const inList = !!LIST && LIST.contains(e);
|
|
270
|
+
if (IN && inList !== (IN.part === 'list')) continue;
|
|
271
|
+
// in an opened-out list, a row already marked is known by where it is, not by its
|
|
272
|
+
// element: a list that reuses its rows as it scrolls shows other mail in the same one
|
|
273
|
+
if ((!inList && kinds.has(e)) || e.disabled || e.closest('[aria-hidden=true],[inert]')) continue;
|
|
261
274
|
const kind = kindOf(e), up = e.parentElement && e.parentElement.closest(SEL);
|
|
262
275
|
if (up && kinds.get(up) === kind && kind !== 'field') continue;
|
|
263
|
-
const b = e.getBoundingClientRect();
|
|
264
|
-
|
|
276
|
+
const b = e.getBoundingClientRect(), CL = inList ? LB : { left: 0, top: 0, right: W, bottom: H };
|
|
277
|
+
// cut off at the list's bottom: the next part shows all of it (it overlaps), unless this is the
|
|
278
|
+
// last; cut off at its top, the part before showed all of it
|
|
279
|
+
if (inList && b.height <= LB.bottom - LB.top && ((!IN.last && b.bottom > LB.bottom + 1) || (LIST.scrollTop > 0 && b.top < LB.top - 1))) continue;
|
|
280
|
+
const x = Math.max(CL.left, b.left), y = Math.max(CL.top, b.top), x2 = Math.min(CL.right, b.right), y2 = Math.min(CL.bottom, b.bottom);
|
|
265
281
|
if (x2 - x < 4 || y2 - y < 4) continue;
|
|
266
282
|
const st = getComputedStyle(e);
|
|
267
283
|
if (st.visibility === 'hidden' || st.pointerEvents === 'none' || +st.opacity === 0) continue;
|
|
@@ -269,10 +285,14 @@ const collectJs = (whole) => `(() => { ${SCROLLER}
|
|
|
269
285
|
const top = document.elementFromPoint((x + x2) / 2, (y + y2) / 2);
|
|
270
286
|
if (!top || !(top === e || e.contains(top) || top.contains(e))) continue;
|
|
271
287
|
${whole ? "if (y >= H - 200 && stuck(e)) continue; // stuck to the window's bottom: left out of the whole page's picture" : ""}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
288
|
+
const full = labelOf(e), at = inList ? Math.round(b.top + LIST.scrollTop) + '|' + kind + '|' + full : '';
|
|
289
|
+
if (at && kept.at.has(at)) { kinds.set(e, kind); continue; }
|
|
290
|
+
// an element marked before, showing something else now: its selector would find it, not this
|
|
291
|
+
const reused = inList && kinds.has(e);
|
|
292
|
+
kinds.set(e, kind); if (at) kept.at.add(at);
|
|
293
|
+
const down = !IN ? 0 : inList ? LIST.scrollTop : y >= LB.bottom ? IN.more || 0 : 0;
|
|
294
|
+
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: y + down, w: x2 - x, h: y2 - y }"};
|
|
295
|
+
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: reused ? '' : cssPath(e) };
|
|
276
296
|
if (full.length > 80) r.text = full.slice(0, 400); // all of a long one (an inbox row), for Watch
|
|
277
297
|
if (e.href && /^https?:/.test(e.href)) r.href = String(e.href).slice(0, 500);
|
|
278
298
|
out.push(r);
|
|
@@ -318,9 +338,9 @@ async function snapshot(page, name) {
|
|
|
318
338
|
// What's fixed or sticky (a menu bar, a cookie banner) is hidden once it's in one,
|
|
319
339
|
// so it shows once, where it was, not on every part. The parts overlap by
|
|
320
340
|
// 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).
|
|
322
|
-
//
|
|
323
|
-
// that gives { error, inside }
|
|
341
|
+
// window is left out (and not marked: collectJs). Where a list scrolls inside the
|
|
342
|
+
// page instead (Gmail's), scrolling the page wouldn't show more: snapshotList
|
|
343
|
+
// opens that list out (or gives { error, inside } when it can't find it).
|
|
324
344
|
const MAX_TALL = 16000, OVERLAP = 200; // pixels
|
|
325
345
|
const PAGE_HEIGHT = `(() => { ${SCROLLER}
|
|
326
346
|
const d = document.scrollingElement || document.documentElement, sc = scroller();
|
|
@@ -341,7 +361,7 @@ async function evaluate(page, expression) {
|
|
|
341
361
|
}
|
|
342
362
|
async function snapshotWhole(page, name) {
|
|
343
363
|
const first = await evaluate(page, PAGE_HEIGHT);
|
|
344
|
-
if (first.inside) return
|
|
364
|
+
if (first.inside) return snapshotList(page, name);
|
|
345
365
|
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
366
|
// a window at a time, overlapping a little; the page can load more as it goes (up to MAX_TALL)
|
|
347
367
|
let info = null, h = first.h, end = 0; const shots = [];
|
|
@@ -367,6 +387,44 @@ async function snapshotWhole(page, name) {
|
|
|
367
387
|
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
388
|
return note ? { ...s, note } : s;
|
|
369
389
|
}
|
|
390
|
+
// The whole of a page whose list scrolls inside it (Gmail's mail), in one tall
|
|
391
|
+
// screenshot: the list opened out to its full length (up to MAX_TALL), the rest
|
|
392
|
+
// of the window as it is, and what's below the list moved down. The list is
|
|
393
|
+
// scrolled a box at a time, overlapping a little, and each part put in its place
|
|
394
|
+
// (screens.mjs stitchListPng). Every row in it is a region, where it is in the
|
|
395
|
+
// tall picture; what's around the list (Compose, the folders) is marked first.
|
|
396
|
+
// The page itself doesn't move, so the window is as it was after.
|
|
397
|
+
async function snapshotList(page, name) {
|
|
398
|
+
const top = await collect(page), sel = top.scroll && top.scroll.selector;
|
|
399
|
+
const box = sel && await evaluate(page, `(() => { const e = document.querySelector(${JSON.stringify(sel)}); if (!e) return null;
|
|
400
|
+
const b = e.getBoundingClientRect(), x = Math.max(0, Math.round(b.left)), y = Math.max(0, Math.round(b.top));
|
|
401
|
+
return { x, y, w: Math.min(innerWidth, Math.round(b.right)) - x, h: Math.min(innerHeight, Math.round(b.bottom)) - y }; })()`);
|
|
402
|
+
if (!box || box.w < 8 || box.h < 40) return { error: "Couldn't find the list that scrolls on this page. Use Scroll down to map the next part of it.", inside: true };
|
|
403
|
+
const room = MAX_TALL - VIEW.h, step = Math.max(20, box.h - Math.min(OVERLAP, Math.floor(box.h / 4)));
|
|
404
|
+
let list = null, rest = null, at = -1, max = 0; const shots = [];
|
|
405
|
+
await evaluate(page, "window.__symbiotWhole = null");
|
|
406
|
+
try {
|
|
407
|
+
for (let y = 0, i = 0; i < 100; i++) {
|
|
408
|
+
const m = await evaluate(page, scrollJs(y, sel)); if (!m || m.y <= at) break; // it wouldn't scroll further
|
|
409
|
+
await sleep(150);
|
|
410
|
+
at = m.y; max = m.max;
|
|
411
|
+
const last = at >= Math.min(max, room);
|
|
412
|
+
if (!list || list.items.length < 200) list = await collect(page, collectJs(false, { selector: sel, part: "list", last }));
|
|
413
|
+
const { data } = await page.send("Page.captureScreenshot", { format: "png" });
|
|
414
|
+
shots.push({ png: Buffer.from(data, "base64"), y: at });
|
|
415
|
+
if (last) break;
|
|
416
|
+
y = Math.min(at + step, Math.min(max, room));
|
|
417
|
+
}
|
|
418
|
+
rest = await collect(page, collectJs(false, { selector: sel, part: "rest", more: at }));
|
|
419
|
+
} finally { await evaluate(page, `window.__symbiotWhole = null; ${scrollJs(0, sel)}`).catch(() => {}); }
|
|
420
|
+
const png = shots.length && stitchListPng(shots, box, at);
|
|
421
|
+
if (!png) return { error: "Couldn't put the list's screenshots together." };
|
|
422
|
+
const tall = VIEW.h + at, items = [...rest.items, ...list.items].filter((r) => r.y < tall).slice(0, 200);
|
|
423
|
+
const s = saveScreen(name, png, { url: top.url, title: top.title, full: true, list: sel, items }, " (whole page)");
|
|
424
|
+
if (s.error || s.note) return s;
|
|
425
|
+
const note = [max > at + 1 && `The list goes on past ${tall} pixels, so this map stops there.`, items.length >= 200 && "It marks the first 200 buttons, links and fields: what's around the list, then the list from the top."].filter(Boolean).join(" ");
|
|
426
|
+
return note ? { ...s, note } : s;
|
|
427
|
+
}
|
|
370
428
|
|
|
371
429
|
// Map a site: open it in the hidden browser and save what's on it as a screen.
|
|
372
430
|
// whole: all of the page in one tall screen (snapshotWhole); a page whose list
|
|
@@ -504,9 +562,14 @@ function actOnRegion(id, regionId, verb, confirmed, check, act) {
|
|
|
504
562
|
const { result } = await page.send("Runtime.evaluate", { expression: find, returnByValue: true });
|
|
505
563
|
const found = !!(result && result.value);
|
|
506
564
|
// 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
|
|
565
|
+
// on the page, not the window: scroll it into the window first (the list, on
|
|
566
|
+
// one whose list was opened out, if the spot is in it).
|
|
508
567
|
let at = found ? result.value : center(r);
|
|
509
|
-
if (!found && s.page.full) {
|
|
568
|
+
if (!found && s.page.full) {
|
|
569
|
+
const y = await evaluate(page, s.page.list ? `(() => { const e = document.querySelector(${JSON.stringify(s.page.list)}); if (!e) return 0;
|
|
570
|
+
const b = e.getBoundingClientRect(); if (${at.y} < b.top) return 0; e.scrollTop = ${at.y} - b.top - e.clientHeight / 2; return e.scrollTop; })()` : `(scrollTo(0, ${at.y} - innerHeight / 2), scrollY)`);
|
|
571
|
+
at = { x: at.x, y: at.y - (Number(y) || 0) };
|
|
572
|
+
}
|
|
510
573
|
const navigating = page.until("Page.frameStartedLoading", 1500);
|
|
511
574
|
const extra = await act(page, at, r);
|
|
512
575
|
if (await navigating) await page.until("Page.loadEventFired", 30000);
|
package/index.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm,
|
|
|
39
39
|
import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
|
|
40
40
|
import { loadScreens, screenImage, blueprint } from "./screens.mjs";
|
|
41
41
|
import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
|
|
42
|
-
import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard } from "./watch.mjs";
|
|
42
|
+
import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine } from "./watch.mjs";
|
|
43
43
|
import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
|
|
44
44
|
import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
|
|
45
45
|
|
|
@@ -367,8 +367,8 @@ async function cmdScreens() {
|
|
|
367
367
|
scroll the page, map what's in the window then
|
|
368
368
|
(a map's "more" says there's more below or above)
|
|
369
369
|
symbiot screens whole <id> map all of that page in one tall screenshot
|
|
370
|
-
(a
|
|
371
|
-
|
|
370
|
+
(where a list scrolls inside the page, like
|
|
371
|
+
Gmail's mail, that list opened out)
|
|
372
372
|
symbiot screens signin <site> sign in once, in Symbiot's browser window
|
|
373
373
|
--yes is needed unless the page's site is under Trusted sites in the app's Settings.
|
|
374
374
|
While the app runs, these use its hidden browser, which stays open a few minutes:
|
|
@@ -392,8 +392,9 @@ async function cmdWatch() {
|
|
|
392
392
|
for (const n of news.slice(0, 20)) console.log(` ${c.d(new Date(n.ts).toLocaleString())} ${n.text.slice(0, 110)} ${c.d(n.name.slice(0, 30))}`);
|
|
393
393
|
return;
|
|
394
394
|
}
|
|
395
|
-
// the Dashboard's cards, for an agent or a status bar (.total is the count on the
|
|
396
|
-
|
|
395
|
+
// the Dashboard's cards, for an agent or a status bar (.total is the count on the
|
|
396
|
+
// Dashboard tab); --line: one line of text, "2 emails · 1 WhatsApp message"
|
|
397
|
+
if (sub === "board") { const b = watchBoard(Math.min(168, Math.max(1, hours))); if (has("line")) return console.log(boardLine(b)); return out(b); }
|
|
397
398
|
if (sub === "seen") return out(a1 ? seenWatch(a1) : { error: "Give the watch's id: symbiot watch board lists them." });
|
|
398
399
|
if (sub === "new") return out(markNews(newsSince(hours), watchState().watches));
|
|
399
400
|
// the same as the app's Draft a reply button: through the app when it runs, so its Agents tab tracks the run
|
|
@@ -422,6 +423,9 @@ async function cmdWatch() {
|
|
|
422
423
|
symbiot watch board [--hours 24] the Dashboard's cards: how many are new on
|
|
423
424
|
each page, and the newest few (JSON, for an
|
|
424
425
|
agent or a status bar: .total is the count)
|
|
426
|
+
symbiot watch board --line the same as one line of text, for a status
|
|
427
|
+
bar: "2 emails · 1 WhatsApp message" (an
|
|
428
|
+
empty line when nothing's new)
|
|
425
429
|
symbiot watch seen <id> set a card's count back to 0, like its Seen
|
|
426
430
|
button (what it found stays in watch new)
|
|
427
431
|
symbiot watch check [id] read them now (JSON)
|