symbiot 0.41.7 โ†’ 0.43.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 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). The tab shows the count, so you see what's waiting from
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,63 @@ 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
+
476
+ ## One Symbiot, everywhere
477
+
478
+ The chat on each Dashboard card and the chat on each task aren't separate bots:
479
+ they're the same Symbiot, and it does what you ask instead of explaining what it
480
+ can't do.
481
+
482
+ - **It acts.** Ask it to do something ("look into this", "close that account",
483
+ "chase Dana") and it hands it to your coding agent right away, with what the page
484
+ showed. The agent has your tools and connectors (MCP, the command line, a
485
+ browser signed in to your linked sites). Something for later becomes a task on
486
+ your list. Anything hard to undo (closing an account, deleting, paying, sending)
487
+ the agent asks you about first, in the Agents tab.
488
+ - **It looks at links.** Ask about the links on a card and it looks each one up the
489
+ way a link preview does (not signed in, no scripts run, nothing downloaded):
490
+ where it really goes, what the page is, and whether it's a file such as an .apk.
491
+ Links into your own network are refused.
492
+ - **It remembers across pages.** What's worth knowing elsewhere (who someone is,
493
+ which account is what, what you decided) is kept in a small local memory,
494
+ `mind.json` in Symbiot's config folder, readable by you only. Each chat gets just
495
+ the parts its question touches, plus the last few things said anywhere in the
496
+ app, so context carries from page to page without re-sending everything.
497
+ Settings โ†’ **What Symbiot remembers** lists it, and forgets any of it.
498
+
429
499
  ## Connect it (once)
430
500
 
431
501
  Pick the AI you want it to write with:
@@ -627,11 +697,14 @@ browser and maps what's in the window then, as a new screen. It scrolls what you
627
697
  mouse wheel would: the page, or the part of it that scrolls on its own (Gmail's
628
698
  list of mail). Scrolling only looks, so it never asks first. Or **Whole page**
629
699
  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
700
+ button, link and field on it marked where it is on the page. The hidden browser
701
+ scrolls down a window at a time and puts the screenshots together, so a menu bar
702
+ that stays at the top shows once, at the top, and a sticky side menu shows in the
703
+ first window only. Where a list scrolls inside the page instead, like Gmail's
704
+ mail, Whole page opens that list out: it scrolls the list a part at a time and
705
+ puts the parts together, with the rest of the window (the search bar, Compose,
706
+ your folders) around it once, so a whole inbox page is one screen with every row
707
+ marked. **Press** on a region clicks it
635
708
  in that hidden browser and maps the page it leads to as a new screen, so map,
636
709
  press, map is how an agent finds its way around a site. **Type** on a field types
637
710
  your text into it there (replacing what was in it) and, with **Type, then Enter**
@@ -695,6 +768,7 @@ symbiot watch add <screen id> --every 15 # watch a mapped page (or: symbiot wa
695
768
  symbiot watch add github # your GitHub notifications (see below)
696
769
  symbiot watch new --hours 24 # what's new, newest first (JSON)
697
770
  symbiot watch board # the Dashboard's cards (JSON; .total is the count, for a status bar)
771
+ symbiot watch board --line # the same as one line: "2 emails ยท 1 WhatsApp message" (empty when nothing's new)
698
772
  symbiot watch seen <watch id> # set a card back to 0, like its Seen button
699
773
  symbiot watch check # read them all now (JSON)
700
774
  symbiot watch draft <id> # Draft a reply to a new email or chat message (see below)
@@ -737,8 +811,9 @@ emails `"mail": true`, and `symbiot watch draft <id>` does the same as the butto
737
811
  A new WhatsApp message (a watched `web.whatsapp.com`) has **Draft a reply** too.
738
812
  There, the agent opens the chat in the hidden browser and types the reply into
739
813
  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
814
+ draft in Symbiot's browser: once the agent is done, **Open in WhatsApp** on the
815
+ message opens that browser at `web.whatsapp.com` as a window, to read the reply
816
+ and send it (close the window after, so Watch can read WhatsApp again). The same guard holds, plus one for chats: a
742
817
  draft's run can't press Enter (in a chat, Enter sends), and in WhatsApp its line
743
818
  breaks are typed as spaces. Add `web.whatsapp.com` under Trusted sites for it.
744
819
  `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 || !HANDOFFS.some((e) => e.path === path)) return { error: "No agent has run in that folder." };
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 and any tasks held for it.
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 a page that scrolls as a whole in one tall screenshot.
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
- const collectJs = (whole) => `(() => { ${SCROLLER}
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 kept = ${whole ? "(window.__symbiotWhole = window.__symbiotWhole || { out: [], kinds: new Map() })" : "{ out: [], kinds: new Map() }"}, out = kept.out, kinds = kept.kinds;
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
- if (kinds.has(e) || e.disabled || e.closest('[aria-hidden=true],[inert]')) continue;
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
- const x = Math.max(0, b.left), y = Math.max(0, b.top), x2 = Math.min(W, b.right), y2 = Math.min(H, b.bottom);
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
- kinds.set(e, kind);
273
- const full = labelOf(e);
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) };
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). 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.
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 { 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 };
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) { const y = await evaluate(page, `(scrollTo(0, ${at.y} - innerHeight / 2), scrollY)`); at = { x: at.x, y: at.y - (Number(y) || 0) }; }
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 page that scrolls as a whole, not a list
371
- inside it like Gmail's: scroll that)
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 Dashboard tab)
396
- if (sub === "board") return out(watchBoard(Math.min(168, Math.max(1, hours))));
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)