symbiot 0.54.2 → 0.54.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +49 -10
- package/adapt.mjs +1 -1
- package/agents.mjs +62 -6
- package/checks.mjs +16 -1
- package/handback.mjs +16 -6
- package/handover.mjs +41 -2
- package/headless.mjs +55 -3
- package/home.mjs +123 -14
- package/index.mjs +41 -6
- package/lanes.mjs +132 -5
- package/package.json +2 -2
- package/post.mjs +310 -41
- package/sandbox.mjs +1 -1
- package/server.mjs +36 -8
- package/ui.mjs +192 -66
- package/watch.mjs +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
|
|
4
4
|
|
|
5
|
+
## 0.54.4 — 2026-10-07
|
|
6
|
+
|
|
7
|
+
- Removed the "Add a task" row from Tasks, since you add tasks through the bottom chat bar, and moved Send to repos beside Close.
|
|
8
|
+
- Fixed agent runs that wait on you, such as unsent drafts or steps only you can take, so they now appear on Home with what to check first.
|
|
9
|
+
- Moved the Reports Refresh button so it sits beside Close instead of under it.
|
|
10
|
+
- Added a Marketing section with post drafts, replies marked "maybe a customer", picture tools and the 4-week table, moved off the Dashboard.
|
|
11
|
+
- Reviewed where sandboxing for agent runs stands, with no changes to the app.
|
|
12
|
+
- Added marks on Connections showing which sites your agents can also use, visible before you link them.
|
|
13
|
+
- Changed the Dashboard to lead with what you watch; posts now appear under Marketing once drafting is possible.
|
|
14
|
+
- Fixed Home's right-side orbs overlapping zone names on screens 1366–1440 pixels wide.
|
|
15
|
+
- Added a "Pick your agent" step on first run that sets up the detected agent, such as Claude Code, in one click.
|
|
16
|
+
|
|
17
|
+
## 0.54.3 — 2026-10-07
|
|
18
|
+
|
|
19
|
+
- Added questions on Home for stuck or failed agent runs, so you can allow access, skip, or run them again right there.
|
|
20
|
+
- Added a ready picture to post drafts whose idea is a screen of an app you run locally; keep it or remove it.
|
|
21
|
+
- Changed post drafting to try once more by itself when every draft gets dropped for claiming things git doesn't show.
|
|
22
|
+
- Added a separate sandbox for trying Symbiot's first-run setup without touching your real settings, drafts or files.
|
|
23
|
+
- Fixed Orca losing track of a repo when a handover renames its folder; Symbiot now adds it again at the new location.
|
|
24
|
+
- Fixed reply tracking treating a signed-out inbox as watched; Home now asks you to sign in again so replies aren't missed.
|
|
25
|
+
- Added a "Where your files disagree" alert under Watching on Home when your files contradict each other on something important.
|
|
26
|
+
|
|
5
27
|
## 0.54.2
|
|
6
28
|
|
|
7
29
|
- **Lighter without a graphics card.** When the liquid is drawn by the CPU (no GPU, or a headless browser like the ones agents check Symbiot's screens in), it used 6 cores or more. Now it draws at a third of the resolution, without the breathing, and only when something actually moved, at most 20 times a second: about a third of the CPU. It does the same when your system asks for less motion. With a graphics card, Home also draws half as often at rest and a sixth as often behind an open panel, where it barely shows.
|
package/README.md
CHANGED
|
@@ -93,8 +93,10 @@ It opens on [the liquid](#the-liquid-a-home-that-shapes-itself-to-you); each of
|
|
|
93
93
|
a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
|
|
94
94
|
- **Week / Standup / Todo** — the write-ups (these use your chosen AI), set as a
|
|
95
95
|
readable page with its dates; **Copy** copies the text as written.
|
|
96
|
-
- **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …).
|
|
97
|
-
|
|
96
|
+
- **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). You add
|
|
97
|
+
a task by telling Symbiot in the bar at the bottom: it files it under the right
|
|
98
|
+
project. Filter by type or repo, then **Send to repos** (at the top, beside
|
|
99
|
+
Close) to hand just those to your agent (see
|
|
98
100
|
[`symbiot push`](#hand-tasks-to-your-coding-agent-symbiot-push)). What your agent
|
|
99
101
|
finishes lands in **Awaiting your review** (see
|
|
100
102
|
[Review and approve](#review-and-approve-the-agents-work)). A task you tick
|
|
@@ -387,7 +389,10 @@ preset only fills in the command box; the saved command is what runs.
|
|
|
387
389
|
- **Orca IDE** (any OS): opens the repo in Orca — either just the repo, to use
|
|
388
390
|
Orca's own agent, or with Claude running in a new tab (allowed `npm test` and
|
|
389
391
|
`node` like the Claude preset, and upgraded the same way). It launches Orca if
|
|
390
|
-
it's closed and waits for it to be ready first.
|
|
392
|
+
it's closed and waits for it to be ready first. When a handover renames a
|
|
393
|
+
repo's folder, Symbiot adds it to Orca again at its new path (matched by its
|
|
394
|
+
GitHub remote, else its name). Orca keeps the old entry, marked missing, until
|
|
395
|
+
you remove it there.
|
|
391
396
|
- **Editors — opens only, no review:** VS Code, Cursor, Windsurf, Zed, Sublime
|
|
392
397
|
Text, IntelliJ IDEA, Neovim (on macOS, also found as `.app` bundles when the CLI
|
|
393
398
|
isn't on your PATH). They just open the repo. Nothing comes back for review unless
|
|
@@ -575,6 +580,15 @@ and the blob dissolves. More than fit across go on a second page. Clicking the o
|
|
|
575
580
|
questions and its Approve under **Needs you here** at the top of its tasks, with its
|
|
576
581
|
handovers that are waiting; an ops run's question on its block in Agents.
|
|
577
582
|
|
|
583
|
+
What stopped and can't go on without you is a question there too, on its lane.
|
|
584
|
+
A handover that couldn't start (an ops run, limited to its own folder, handed
|
|
585
|
+
"needs a run that can edit `~/Company`" to ops) asks **Allow this run access to
|
|
586
|
+
~/Company?**: **Allow** starts a run of its own on it, allowed to read, edit and
|
|
587
|
+
move files in that folder (that run only), and what it does goes back to the agent
|
|
588
|
+
that asked; **Skip** lets it go, and that agent reads that you skipped it. It asks
|
|
589
|
+
for 3 days, or until the same thing goes through another way. A run that ended in
|
|
590
|
+
an error asks **Run it again?** for a day, until another run starts there.
|
|
591
|
+
|
|
578
592
|
Under the band, around the core, are the parts of the app, grouped the
|
|
579
593
|
**P.A.R.A.** way, each group a row with its name at its start, always in the same
|
|
580
594
|
place and order:
|
|
@@ -673,28 +687,49 @@ release tags and dated `CHANGELOG.md` sections in the repos Symbiot already scan
|
|
|
673
687
|
and nothing else. **It never posts by itself**, and it doesn't schedule.
|
|
674
688
|
|
|
675
689
|
- **Your voice.** Put 5–10 posts you wrote in `~/.config/symbiot/voice.md`, with a
|
|
676
|
-
line of `---` between each. Or link LinkedIn (above
|
|
677
|
-
|
|
690
|
+
line of `---` between each. Or link LinkedIn (above, or **Link LinkedIn** under
|
|
691
|
+
**Marketing**) and click **Fill from LinkedIn** there (`symbiot post voice --linkedin`), which reads your
|
|
678
692
|
recent posts in Symbiot's signed-in browser. Without examples it doesn't draft.
|
|
679
693
|
- **Only what git shows.** Your AI gets the week as numbered facts and must say
|
|
680
694
|
which ones each post uses. A post that names a number, version, name or user
|
|
681
|
-
count the facts don't show is sent back once, then dropped.
|
|
682
|
-
|
|
695
|
+
count the facts don't show is sent back once, then dropped. When every draft is
|
|
696
|
+
dropped, Symbiot tries once more from the start by itself before giving up.
|
|
697
|
+
- **You approve each one.** The drafts wait under **Marketing**, a section of its
|
|
698
|
+
own (its orb shows on Home once posts can be drafted), with **Approve**,
|
|
683
699
|
**Edit** and **Skip**. Approve copies the post to your clipboard and opens
|
|
684
700
|
LinkedIn's share box: you paste it and post it yourself.
|
|
701
|
+
- **Pictures and videos.** Each draft says in a line what picture or short video
|
|
702
|
+
would show it best. Add one of yours (**Add picture or video**: PNG, JPG, GIF,
|
|
703
|
+
MP4, MOV, WebM), or click **From a page** and give a web address (your app on
|
|
704
|
+
`localhost` works) to **Take a picture** of it, sharp enough for a feed, or
|
|
705
|
+
**Record a clip**: 5–30 seconds of it, scrolling slowly down, as an MP4 (needs
|
|
706
|
+
`ffmpeg`). A post takes a video or up to 20 pictures, as LinkedIn does. They're
|
|
707
|
+
copies, kept in `~/.config/symbiot/post-media/`; Approve opens their folder for
|
|
708
|
+
you to add them with LinkedIn's photo or video button. A draft whose idea is a
|
|
709
|
+
screen of an app you run here (listening on `localhost`, started in that repo's
|
|
710
|
+
folder, or Symbiot's own app: "the new Reports view" opens Reports) arrives with
|
|
711
|
+
its picture already on it: keep it or remove it.
|
|
685
712
|
- **Everything is logged** in `~/.config/symbiot/posts-log.jsonl`: each draft,
|
|
686
713
|
edit, approval and skip, with its text, date and platform.
|
|
687
714
|
- **Replies.** Linked LinkedIn is watched like an inbox: new comments and mentions
|
|
688
|
-
show on its Dashboard card, marked **maybe a customer**
|
|
689
|
-
install it, what it costs or about team use. **Draft a reply** has your agent
|
|
715
|
+
show under **Marketing** (and on its Dashboard card), marked **maybe a customer**
|
|
716
|
+
when they ask how to install it, what it costs or about team use. **Draft a reply** has your agent
|
|
690
717
|
type a reply into the comment box without posting it (it can't press Post,
|
|
691
718
|
Comment, Reply or Send), and write it in its brief for you to paste.
|
|
719
|
+
- **The 4-week test.** Marketing keeps a row a week: posts published (and how
|
|
720
|
+
many had a picture), replies, profile visits (LinkedIn's own count), installs
|
|
721
|
+
(npm downloads of the packages your posts are about) and pricing questions.
|
|
722
|
+
Week 1 is the first week a post goes out; to move it, put
|
|
723
|
+
`{ "start": "2026-10-13" }` in `~/.config/symbiot/post-test.json`.
|
|
692
724
|
|
|
693
725
|
```bash
|
|
694
726
|
symbiot post # draft this week's 3 posts
|
|
695
727
|
symbiot post list # the drafts waiting on you
|
|
696
728
|
symbiot post approve <id> # copy it, and get LinkedIn's share box (--open opens it)
|
|
697
729
|
symbiot post edit <id> "text" # change a draft's words
|
|
730
|
+
symbiot post add <id> <file> # put a picture or video of yours on it
|
|
731
|
+
symbiot post page <id> <page> # a picture of a web page on it (--clip: a short video)
|
|
732
|
+
symbiot post remove <id> <m> # take a picture or video off it
|
|
698
733
|
symbiot post skip <id> # drop one
|
|
699
734
|
symbiot post log # everything that happened
|
|
700
735
|
```
|
|
@@ -765,7 +800,9 @@ Every brief also tells the agent what else it can leave in `.symbiot/`:
|
|
|
765
800
|
gets it as a task and its agent starts, told what was asked for and what to do
|
|
766
801
|
next. The task it's for says so in its chat, and you get a notification. The
|
|
767
802
|
Dashboard lists **Waiting on replies**, with **Stop waiting** on each. Anything
|
|
768
|
-
the reply leaves for you to decide comes back as that agent's question.
|
|
803
|
+
the reply leaves for you to decide comes back as that agent's question. While
|
|
804
|
+
one waits and nothing can see the reply (no inbox watched and Email off, or the
|
|
805
|
+
watched inbox signed out), Home and the Dashboard say so.
|
|
769
806
|
- **A report** (findings, an audit, a plan, a pitch): any `.md` named for what it
|
|
770
807
|
is, with a `# ` title. **Reports** in the app lists every one your runs left,
|
|
771
808
|
in your repos and in runs of their own, newest first, unread ones marked; click
|
|
@@ -861,6 +898,8 @@ CSV and Markdown tables, front matter and "Owner:" lines, and examples are never
|
|
|
861
898
|
used as facts. The list, most pressing first and each with its files, is under
|
|
862
899
|
**Settings → Knowledge folders**; it's checked again whenever a file changes, and
|
|
863
900
|
**Check again** does it now. It's kept in `~/.config/symbiot/checks.json`, yours only.
|
|
901
|
+
A clash that matters (a deadline on someone's leave or a holiday, a renewal or
|
|
902
|
+
price told two ways) also shows on Home, under Watching, until you open it.
|
|
864
903
|
|
|
865
904
|
- **Examples aren't facts.** Say which paths in a folder hold worked examples
|
|
866
905
|
(`--examples templates/,active/`, or the second box in Settings; `templates/`
|
package/adapt.mjs
CHANGED
|
@@ -48,7 +48,7 @@ const MIN_EVENTS = 12; // under this many uses, adaptation fad
|
|
|
48
48
|
const HICK_COVER = 0.9, HICK_MIN = 3, HICK_MAX = 7;
|
|
49
49
|
const HYSTERESIS = 0.15;
|
|
50
50
|
const FITTS = { a: 0.1, b: 0.15 }; // seconds; only ratios matter here
|
|
51
|
-
const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings", "reports"];
|
|
51
|
+
const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings", "reports", "marketing"];
|
|
52
52
|
|
|
53
53
|
function loadAdapt(file = ADAPT_FILE) {
|
|
54
54
|
try { const d = JSON.parse(readFileSync(file, "utf8")); return { shapes: d.shapes || {}, trans: d.trans || {}, hours: d.hours || {}, modes: d.modes || {}, slots: d.slots || {}, shown: d.shown || null, events: d.events || 0 }; }
|
package/agents.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync
|
|
|
9
9
|
import { randomBytes, createHash } from "node:crypto";
|
|
10
10
|
import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
|
|
11
11
|
import { parseRun, lastRunText, readRunLog } from "./work.mjs";
|
|
12
|
-
import { parseFacts } from "./handover.mjs";
|
|
12
|
+
import { parseFacts, leftToYou } from "./handover.mjs";
|
|
13
13
|
import { scanRoots, repoPathMap } from "./scan.mjs";
|
|
14
14
|
import { knowledgeFolders } from "./knowledge.mjs";
|
|
15
15
|
|
|
@@ -141,6 +141,17 @@ function connectorsLine(tmpl = handoffCmd(), file, linked) {
|
|
|
141
141
|
// For Settings → Handoff: the connectors, whether this command's runs get them, and
|
|
142
142
|
// the sites linked in Symbiot that aren't Claude connectors (so runs can't use them).
|
|
143
143
|
function connectorsInfo(file) { const conns = claudeConnectors("", file); return { claude: isClaudeCmd(handoffCmd()), list: conns.map(({ name, ready }) => ({ name, ready })), links: linkedConnectors(conns) }; }
|
|
144
|
+
// Connections, before you link: which sites agent runs can use too. A link signs
|
|
145
|
+
// Symbiot's browser in; a run reaches a site only through Claude's own connector for
|
|
146
|
+
// it (LINK_CONNECTOR), and only with Claude Code as your agent (or, with none picked
|
|
147
|
+
// yet, on this computer to pick). { claude, sites: { id: { name, connector, ready } } }:
|
|
148
|
+
// connector "" while you haven't connected it in claude.ai.
|
|
149
|
+
const claudeAgent = () => { const c = handoffCmd(); return isClaudeCmd(c) || (!c && hasCmd("claude")); };
|
|
150
|
+
function linkReach(conns = claudeConnectors(), claude = claudeAgent()) {
|
|
151
|
+
const sites = {};
|
|
152
|
+
for (const [id, [name, re]] of Object.entries(LINK_CONNECTOR)) { const c = conns.find((x) => re.test(x.name)); sites[id] = { name, connector: c ? c.name : "", ready: !!c && c.ready }; }
|
|
153
|
+
return { claude, sites };
|
|
154
|
+
}
|
|
144
155
|
const fillHandoff = (tmpl, repoPath, prompt = HANDOFF_PROMPT) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(prompt));
|
|
145
156
|
// ---- agents work on their own --------------------------------------------------------
|
|
146
157
|
// Talk to an agent and it just does the work: permission checks skipped, one
|
|
@@ -271,14 +282,54 @@ function earlierRuns() {
|
|
|
271
282
|
for (const r of loadRuns()) {
|
|
272
283
|
if (tracked.has(r.path) || !existsSync(join(r.path, ".symbiot"))) continue;
|
|
273
284
|
const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path), facts = busy ? [] : factsOf(r.path);
|
|
274
|
-
if (!busy && !ask.questions.length && !wait && !facts.length) continue;
|
|
275
285
|
const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
|
|
276
|
-
let
|
|
277
|
-
|
|
286
|
+
let end = Date.now(); if (!busy) try { end = statSync(log).mtimeMs; } catch {}
|
|
287
|
+
const needs = busy ? null : needsOf(r.path, { final: lastFinal(r.path), waiting: wait, end });
|
|
288
|
+
if (!busy && !ask.questions.length && !wait && !facts.length && !needs) continue;
|
|
289
|
+
let tail = "", work = null, progress = null; try { ({ tail, work, progress } = workOf(r.path, readRunLog(log))); } catch {}
|
|
290
|
+
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, startedAt, endedAt: busy ? null : end, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, needs, remember: facts.length ? facts : null, fromHeld: false });
|
|
278
291
|
}
|
|
279
292
|
return out;
|
|
280
293
|
}
|
|
281
294
|
|
|
295
|
+
// ---- what a finished run left to you ----------------------------------------
|
|
296
|
+
// A run that ended saying something waits on you (handover.mjs leftToYou: "the
|
|
297
|
+
// draft isn't sent", "needs sudo, and only you can do that"), or waiting on a step
|
|
298
|
+
// an answer of yours picked (waitingFor), needs you, and says so: on its block in
|
|
299
|
+
// Agents and on Home, with what to check first. It did end, so before this nothing
|
|
300
|
+
// showed it: Home said nothing needed you while a reply to Cale sat in Drafts. It
|
|
301
|
+
// stays for NEEDS_FOR, until you go ahead or skip it (settled.json keeps which, per
|
|
302
|
+
// folder), a newer run there says something else, or a newer ops run took up the
|
|
303
|
+
// same task. { kind: "approve"|"step", what, check, label, key, waiting? } or null.
|
|
304
|
+
const NEEDS_FOR = 3 * 86400000, SETTLED = join(CONFIG_DIR, "settled.json"), ACT_DIR = join(CONFIG_DIR, "drafts") + "/";
|
|
305
|
+
const loadSettled = () => { try { const o = JSON.parse(readFileSync(SETTLED, "utf8")); return o && typeof o === "object" && !Array.isArray(o) ? o : {}; } catch { return {}; } };
|
|
306
|
+
function settleNeeds(path, key) {
|
|
307
|
+
const o = loadSettled(); o[path] = String(key || "");
|
|
308
|
+
try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(SETTLED, JSON.stringify(o, null, 2), { mode: 0o600 }); } catch {}
|
|
309
|
+
}
|
|
310
|
+
// A streaming run's last words (its final answer), read once per change of its log.
|
|
311
|
+
const FINALS = new Map();
|
|
312
|
+
function lastFinal(path) {
|
|
313
|
+
const log = join(path, ".symbiot", "agent.log"); let m = 0; try { m = statSync(log).mtimeMs; } catch { return ""; }
|
|
314
|
+
const c = FINALS.get(path); if (c && c.m === m) return c.final;
|
|
315
|
+
let final = ""; try { const w = parseRun(lastRunText(readRunLog(log))); final = w.stream ? w.final : ""; } catch {}
|
|
316
|
+
FINALS.set(path, { m, final }); return final;
|
|
317
|
+
}
|
|
318
|
+
const firstTask = (path) => (readSymbiot(path, "TASKS.md").match(/^\s*-\s*\[[ x]\]\s*(.+)$/im) || [])[1] || "";
|
|
319
|
+
// an ops run (a folder of its own each time) whose task a newer ops run took up
|
|
320
|
+
function takenUp(path, end) {
|
|
321
|
+
if (!path.startsWith(ACT_DIR)) return false;
|
|
322
|
+
const t = firstTask(path); return !!t && loadRuns().some((r) => r.path !== path && r.path.startsWith(ACT_DIR) && Number(r.startedAt) > end && sameTask(firstTask(r.path), t));
|
|
323
|
+
}
|
|
324
|
+
function needsOf(path, { final = "", waiting = null, end = Date.now(), now = Date.now() } = {}) {
|
|
325
|
+
if (now - end > NEEDS_FOR) return null;
|
|
326
|
+
const n = waiting ? { kind: "step", what: clipWords(String(waiting.step || "").replace(/^\s*👤\s*(You\s*(\([^)]*\))?:)?\s*/, "").trim(), 240), check: "", label: "", waiting: true } : leftToYou(final);
|
|
327
|
+
if (!n || !n.what) return null;
|
|
328
|
+
const key = digest(n.what);
|
|
329
|
+
if (loadSettled()[path] === key || takenUp(path, end)) return null;
|
|
330
|
+
return { ...n, key };
|
|
331
|
+
}
|
|
332
|
+
|
|
282
333
|
// ---- a step that's yours: wait for it -----------------------------------------
|
|
283
334
|
// An answer that picks a "👤 You:" option leaves a step for the user to do (put a
|
|
284
335
|
// key in .env, allow a command in Settings). Starting the agent before it's done
|
|
@@ -484,6 +535,9 @@ const AGENT_LIST = [
|
|
|
484
535
|
["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
|
|
485
536
|
["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
|
|
486
537
|
];
|
|
538
|
+
// The agent to offer a new user in one click (Home's "Pick your agent"): the first
|
|
539
|
+
// coding agent on this computer, in AGENT_LIST's order (Claude Code first). null if none.
|
|
540
|
+
function pickAgent() { const a = AGENT_LIST.find(([cmd]) => hasCmd(cmd)); return a ? { label: a[1], name: a[1].split(" — ")[0], tmpl: a[2] } : null; }
|
|
487
541
|
function detectHandoffs() {
|
|
488
542
|
const editors = [];
|
|
489
543
|
for (const [cmd, label, app] of IDE_LIST) {
|
|
@@ -885,8 +939,10 @@ function agentsList() {
|
|
|
885
939
|
const { tail, work, progress } = workOf(e.path, readRunLog(e.log));
|
|
886
940
|
const first = !seen.has(e.path); seen.add(e.path);
|
|
887
941
|
const facts = first && e.status !== "running" ? factsOf(e.path) : [];
|
|
888
|
-
|
|
942
|
+
const waiting = first && e.status !== "running" ? waitingFor(e.path) : null;
|
|
943
|
+
const needs = first && e.status !== "running" ? needsOf(e.path, { final: work ? tail : "", waiting, end: e.endedAt || Date.now() }) : null;
|
|
944
|
+
return { id: e.id, name: e.name, path: e.path, status: e.status, startedAt: e.startedAt, endedAt: e.endedAt, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting, needs, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
|
|
889
945
|
}).concat(earlierRuns());
|
|
890
946
|
}
|
|
891
947
|
|
|
892
|
-
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , autoAllow, autoAllowSweep, allowlistInWork, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
|
|
948
|
+
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR , autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
|
package/checks.mjs
CHANGED
|
@@ -647,5 +647,20 @@ function checksState({ file = CHECKS_FILE } = {}) {
|
|
|
647
647
|
try { const d = JSON.parse(readFileSync(file, "utf8")); return { at: d.at || 0, clashes: d.clashes || [], counts: d.counts || {}, officeDays: d.officeDays || [], folders: d.folders || [], ms: d.ms || 0 }; }
|
|
648
648
|
catch { return { at: 0, clashes: [], counts: {}, officeDays: [], folders: [], ms: 0 }; }
|
|
649
649
|
}
|
|
650
|
+
// Home's "Where your files disagree" droplet: the high clashes (a deadline on
|
|
651
|
+
// someone's leave or a holiday, a renewal or price told two ways) you haven't
|
|
652
|
+
// opened from it yet. Opening it marks them seen (checks-seen.json), so the same
|
|
653
|
+
// clash reaches you once; a new one, or one that changes, comes back.
|
|
654
|
+
const SEEN_FILE = join(CONFIG_DIR, "checks-seen.json");
|
|
655
|
+
const clashKey = (c) => c.kind + "|" + c.text;
|
|
656
|
+
function seenClashes({ file = SEEN_FILE } = {}) { try { const d = JSON.parse(readFileSync(file, "utf8")); return Array.isArray(d) ? d : []; } catch { return []; } }
|
|
657
|
+
function newClashes({ state = checksState(), seen = seenClashes() } = {}) {
|
|
658
|
+
const s = new Set(seen);
|
|
659
|
+
return (state.clashes || []).filter((c) => c.severity === "high" && !s.has(clashKey(c)));
|
|
660
|
+
}
|
|
661
|
+
function markClashesSeen({ state = checksState(), file = SEEN_FILE } = {}) {
|
|
662
|
+
const keys = [...new Set([...seenClashes({ file }), ...(state.clashes || []).filter((c) => c.severity === "high").map(clashKey)])].slice(-MAX_CLASHES);
|
|
663
|
+
try { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, JSON.stringify(keys), { mode: 0o600 }); return { ok: true }; } catch { return { error: "Couldn't write " + file + "." }; }
|
|
664
|
+
}
|
|
650
665
|
|
|
651
|
-
export { CHECKS_FILE, parseDates, timeOf, loadFiles, extractFacts, findClashes, runChecks, checksState };
|
|
666
|
+
export { CHECKS_FILE, parseDates, timeOf, loadFiles, extractFacts, findClashes, runChecks, checksState, newClashes, markClashesSeen, seenClashes };
|
package/handback.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { CONFIG_DIR, loadConfig, loadTasks, saveTasks, sameTask, clipWords } fro
|
|
|
30
30
|
import { OPS, parseAwaiting } from "./handover.mjs";
|
|
31
31
|
import { FACTS, factsOf, knownRun, loadRuns, HANDOFFS, runningHandoff } from "./agents.mjs";
|
|
32
32
|
import { remember, actIn } from "./mind.mjs";
|
|
33
|
-
import { newsSince, watchState, isMail } from "./watch.mjs";
|
|
33
|
+
import { newsSince, watchState, isMail, isSignedOut } from "./watch.mjs";
|
|
34
34
|
import { inboxMail } from "./mail.mjs";
|
|
35
35
|
import { desktopNotify } from "./desktop.mjs";
|
|
36
36
|
import { repoPathMap } from "./scan.mjs";
|
|
@@ -163,11 +163,21 @@ function awaitTick({ map = repoPathMap(), now = Date.now(), news, inbox, act, no
|
|
|
163
163
|
if (added.length || replied.length) saveWaits(d);
|
|
164
164
|
return { added, replied };
|
|
165
165
|
}
|
|
166
|
-
//
|
|
167
|
-
|
|
166
|
+
// Can a reply be noticed? Email on (the inbox on this computer), or a watched
|
|
167
|
+
// inbox that's signed in: a signed-out one reads nothing, so it doesn't count.
|
|
168
|
+
// { sees, signedOut: [the signed-out inboxes' names] }
|
|
169
|
+
function inboxSight({ mail, watches } = {}) {
|
|
170
|
+
if (!mail) { try { mail = loadConfig().mail || {}; } catch { mail = {}; } }
|
|
171
|
+
if (!watches) { try { watches = watchState().watches; } catch { watches = []; } }
|
|
172
|
+
const inboxes = watches.filter((w) => isMail(w.url)), out = inboxes.filter(isSignedOut);
|
|
173
|
+
return { sees: !!mail.enabled || inboxes.length > out.length, signedOut: out.map((w) => w.name) };
|
|
174
|
+
}
|
|
175
|
+
// The Dashboard's list: what's still waiting, then what came in lately, newest
|
|
176
|
+
// first; and, while one waits, whether anything can see the reply arrive.
|
|
177
|
+
function awaitingState({ sight = inboxSight } = {}) {
|
|
168
178
|
const view = (w) => ({ id: w.id, from: w.from.lane, to: w.to, subject: w.subject, asked: w.asked, task: w.task, lane: w.lane, sent: w.sent, status: w.status, ...(w.reply ? { reply: w.reply } : {}), ...(w.handed ? { handed: w.handed } : {}), ...(w.error ? { error: w.error } : {}) });
|
|
169
|
-
const all = loadWaits().waits.slice().reverse();
|
|
170
|
-
return { waits: [...
|
|
179
|
+
const all = loadWaits().waits.slice().reverse(), open = all.filter((w) => w.status === "waiting");
|
|
180
|
+
return { waits: [...open, ...all.filter((w) => w.status !== "waiting" && w.status !== "stopped").slice(0, 10)].map(view), ...(open.length ? { sight: sight() } : {}) };
|
|
171
181
|
}
|
|
172
182
|
// Stop waiting (its button): it stays in the file, so the run's AWAITING.json doesn't bring it back.
|
|
173
183
|
function stopWaiting(id) {
|
|
@@ -175,4 +185,4 @@ function stopWaiting(id) {
|
|
|
175
185
|
w.status = "stopped"; return saveWaits(d) ? { ok: true, ...awaitingState() } : { error: "Couldn't write " + AWAIT_FILE + "." };
|
|
176
186
|
}
|
|
177
187
|
|
|
178
|
-
export { AWAIT_FILE, RUNS_DIR, runFolders, laneOf, runTitle, subjectCore, keepFacts, skipFacts, loadWaits, collectWaits, answers, replyTo, nextRequest, awaitTick, awaitingState, stopWaiting };
|
|
188
|
+
export { AWAIT_FILE, RUNS_DIR, runFolders, laneOf, runTitle, subjectCore, keepFacts, skipFacts, loadWaits, collectWaits, answers, replyTo, nextRequest, awaitTick, inboxSight, awaitingState, stopWaiting };
|
package/handover.mjs
CHANGED
|
@@ -24,7 +24,7 @@ function handoverRules(lanes = [], here = "") {
|
|
|
24
24
|
}
|
|
25
25
|
// The rule for options in QUESTIONS.md: the user gets only what no agent can do,
|
|
26
26
|
// and the option says which kind it is.
|
|
27
|
-
const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `👤 You (only you: <which>):`, e.g. `👤 You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a 👤 step: do it, or hand it over. An option where picking it is enough starts with `🤖 Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it.";
|
|
27
|
+
const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `👤 You (only you: <which>):`, e.g. `👤 You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a 👤 step: do it, or hand it over. An option where picking it is enough starts with `🤖 Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it. Whatever waits on the user's OK (sending, posting, deleting, paying, closing) or on a step only they can take goes in QUESTIONS.md as a question, with what they should check first in its context line, never only in your last message: they aren't asked about what your last message says.";
|
|
28
28
|
|
|
29
29
|
// What a run hands back to Symbiot itself (handback.mjs reads it): facts for
|
|
30
30
|
// memory, and emails that wait on a reply, which Symbiot then watches for; and
|
|
@@ -53,6 +53,45 @@ function parseAwaiting(text) {
|
|
|
53
53
|
})).filter((x) => x.subject).slice(0, 20);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// ---- what a run's last words leave to the user ----------------------------------
|
|
57
|
+
// A run that ends saying something waits on the user ("the draft isn't sent",
|
|
58
|
+
// "only you can do that", "needs sudo") without asking it in QUESTIONS.md left it
|
|
59
|
+
// where nothing showed it: the email to Jono sat in Drafts and nothing asked for
|
|
60
|
+
// the OK. leftToYou finds it: { kind, what, check, label } or null.
|
|
61
|
+
// kind "approve": what an agent does once the user says go (send, post, delete, pay,
|
|
62
|
+
// close); "step": what only the user can do (sudo, a password, signing in). what:
|
|
63
|
+
// the sentence that says so; check: what to check first (the catch it named, else
|
|
64
|
+
// the point's next sentence); label: the point's bold name ("Reply to Cale").
|
|
65
|
+
const HELD = /\b(?:isn['’]t|wasn['’]t|not|never|hasn['’]t been|haven['’]t|didn['’]t) (?:yet )?(?:been )?(?:sent|send it|posted|post it)\b|\bunsent\b|\bbefore you (?:send|post)\b|\bfor you to (?:send|post)\b|\b(?:send|sending|post|posting) it yourself\b|\byou(?:['’]ll| will) (?:send|post) it\b|\bwaits? (?:on|for) your (?:ok|okay|go-ahead|approval|say-so)\b/i;
|
|
66
|
+
const YOURS = /\bonly you (?:can|could|do)\b|\b(?:needs?|waits? (?:on|for)|waiting (?:on|for)) (?:your (?!review)\w+|you to)\b|\btype your password\b|\bneeds sudo\b/i;
|
|
67
|
+
const DID = /\b(?:I|we)(?:['’]ve| have)? (?:sent|posted|deleted|paid|closed)\b|\balready (?:went out|sent|posted)\b|\bnothing (?:was |is )?(?:sent|posted)\b/i;
|
|
68
|
+
const ACTS = /\b(?:send|sent|sending|post|posted|publish|delete|remove|pay|close|cancel)\b/i;
|
|
69
|
+
const CATCH = /\b(?:catch|before you|check|make sure|careful|fix (?:that|it)|first)\b/i;
|
|
70
|
+
const NOT_THEIRS = /\bconnectors?\b|\bclaude\.ai\b/i; // a connector to sign in again shows on Home already
|
|
71
|
+
const SENDS = /draft|reply|email|mail|message|post/i;
|
|
72
|
+
function leftToYou(text) {
|
|
73
|
+
const points = String(text || "").replace(/```[\s\S]*?```/g, (m) => m.replace(/\s+/g, " ")).split(/\n+|\s+-\s+(?=\*\*)/).map((p) => p.replace(/^\s*[-*•]\s+/, "").trim()).filter(Boolean)
|
|
74
|
+
.map((p) => { const lm = p.match(/^\*\*([^*]{2,60}?):?\*\*:?\s*/), body = lm ? p.slice(lm[0].length) : p;
|
|
75
|
+
return { label: lm ? lm[1].trim() : "", ss: body.split(/(?<=[.!?:])\s+(?=[A-Z`"*(])/).map((s) => s.trim()).filter(Boolean), off: NOT_THEIRS.test(p) }; });
|
|
76
|
+
const clean = (x, n) => { x = String(x || "").replace(/\*\*/g, "").replace(/\s+/g, " ").trim(); return x.length > n ? x.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : x; };
|
|
77
|
+
for (let k = 0; k < points.length; k++) {
|
|
78
|
+
const { label, ss, off } = points[k]; if (off) continue;
|
|
79
|
+
const i = ss.findIndex((s) => !DID.test(s) && (HELD.test(s) || YOURS.test(s)));
|
|
80
|
+
if (i < 0) continue;
|
|
81
|
+
const s = ss[i], next = points[k + 1] && !points[k + 1].off ? points[k + 1].ss : [];
|
|
82
|
+
// what to check first: the catch it named (in this point, then the next), else what it says next
|
|
83
|
+
const catches = ss.filter((x, j) => j !== i && CATCH.test(x) && !DID.test(x));
|
|
84
|
+
const elsewhere = points.filter((p, j) => j !== k && !p.off).flatMap((p) => p.ss.filter((x) => /\byourself\b|\bbefore you\b|\bcatch\b/i.test(x) && !DID.test(x)).map((x) => (p.label ? `${p.label}: ${x}` : x)));
|
|
85
|
+
let check = (catches.length ? catches.slice(0, 2).join(" ") : elsewhere[0]) || ss[i + 1] || next[0] || "";
|
|
86
|
+
if (/:$/.test(check)) { const seq = [...ss.slice(i + 1), ...next, ...((points[k + 2] && points[k + 2].ss) || [])], at = seq.indexOf(check); if (at >= 0 && seq[at + 1]) check += " " + seq[at + 1]; } // "paste this:" and then the command
|
|
87
|
+
// "The draft isn't sent." says which draft only in its own point ("**Draft:** it's in Gmail, to …")
|
|
88
|
+
const about = !label && SENDS.test(s) ? points.find((p) => p.label && SENDS.test(p.label) && p.ss.length) : null;
|
|
89
|
+
const what = label ? `${label}: ${s}` : about ? `${s} ${about.label}: ${about.ss[0]}` : s;
|
|
90
|
+
return { kind: HELD.test(s) || ACTS.test(s) ? "approve" : "step", what: clean(what, 240), check: clean(check === s ? "" : check, 240), label: clean(label || (about && about.label), 60) };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
56
95
|
// What an agent handed over: [{ lane, text }] from its .symbiot/HANDOFF.md.
|
|
57
96
|
function parseHandoffs(md) {
|
|
58
97
|
const out = []; let cur = null;
|
|
@@ -65,4 +104,4 @@ function parseHandoffs(md) {
|
|
|
65
104
|
return out.map((h) => ({ lane: h.lane, text: h.text.trim().slice(0, HANDOVER_MAX) })).filter((h) => h.lane && h.text);
|
|
66
105
|
}
|
|
67
106
|
|
|
68
|
-
export { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs };
|
|
107
|
+
export { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs, leftToYou };
|
package/headless.mjs
CHANGED
|
@@ -80,7 +80,7 @@ function connect(proc) {
|
|
|
80
80
|
function done(v) { clearTimeout(t); listeners.delete(f); resolve(v); }
|
|
81
81
|
listeners.add(f);
|
|
82
82
|
});
|
|
83
|
-
return { send, until, on: (f) => listeners.add(f), closed: () => closed };
|
|
83
|
+
return { send, until, on: (f) => { listeners.add(f); return () => listeners.delete(f); }, closed: () => closed };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
// One browser at a time: they share a profile, and Chrome locks it.
|
|
@@ -139,7 +139,9 @@ async function launch() {
|
|
|
139
139
|
// Sites serve "HeadlessChrome" something else (or a block page): look like the browser it is.
|
|
140
140
|
const { userAgent } = await c.send("Browser.getVersion");
|
|
141
141
|
if (userAgent) await send("Network.setUserAgentOverride", { userAgent: userAgent.replace(/HeadlessChrome/g, "Chrome") });
|
|
142
|
-
|
|
142
|
+
// every `method` event on this tab, until the function it gives is called
|
|
143
|
+
const on = (method, f) => c.on((m) => { if (m && m.sessionId === sessionId && m.method === method) f(m.params || {}); });
|
|
144
|
+
b.page = { send, until, idle, on };
|
|
143
145
|
return b;
|
|
144
146
|
})();
|
|
145
147
|
return await Promise.race([run, failed]);
|
|
@@ -497,6 +499,56 @@ function readTexts(input, selector, { scrolls = 3 } = {}) {
|
|
|
497
499
|
}, false)).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
498
500
|
}
|
|
499
501
|
|
|
502
|
+
// A picture, or a short clip, of a page, for a post (post.mjs): only on your
|
|
503
|
+
// click, and it only looks, like readPage. The picture is the window at twice its
|
|
504
|
+
// pixels, so it stays sharp in a feed: { png, url, title }. The clip records the
|
|
505
|
+
// window for `seconds` (the DevTools screencast: a frame each time the page
|
|
506
|
+
// changes, with its time), scrolling slowly down a page longer than the window,
|
|
507
|
+
// so even a still page moves: { frames: [{ ts, jpeg }], end, url, title }, the
|
|
508
|
+
// times in seconds. post.mjs makes it a video.
|
|
509
|
+
const CLIP = { min: 3, max: 30, speed: 450 }; // seconds; pixels a second, scrolling
|
|
510
|
+
const SCROLL_THROUGH = (ms) => `(() => { ${SCROLLER}
|
|
511
|
+
const sc = scroller(); if (!sc) return 0;
|
|
512
|
+
const hold = 800, run = Math.max(500, ${ms} - 2 * hold), dist = Math.min(sc.scrollHeight - sc.clientHeight, run / 1000 * ${CLIP.speed}), t0 = performance.now();
|
|
513
|
+
const ease = (x) => x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
|
|
514
|
+
(function step(now) { const k = Math.min(1, Math.max(0, (now - t0 - hold) / run)); sc.scrollTop = Math.round(dist * ease(k)); if (k < 1) requestAnimationFrame(step); })(t0);
|
|
515
|
+
return Math.round(dist);
|
|
516
|
+
})()`;
|
|
517
|
+
function pagePicture(input) {
|
|
518
|
+
const url = siteUrl(input);
|
|
519
|
+
if (!url) return Promise.resolve({ error: "Give a page: a web address (localhost:3000 works), a host (github.com/you) or a site's name." });
|
|
520
|
+
return oneAtATime(() => withPage(async (page) => {
|
|
521
|
+
await page.send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 2, mobile: false });
|
|
522
|
+
try {
|
|
523
|
+
await open(page, url);
|
|
524
|
+
const { data } = await page.send("Page.captureScreenshot", { format: "png" });
|
|
525
|
+
return { png: Buffer.from(data, "base64"), url: String((await evaluate(page, "location.href")) || url), title: String((await evaluate(page, "document.title")) || "") };
|
|
526
|
+
} finally { await page.send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 1, mobile: false }).catch(() => {}); }
|
|
527
|
+
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
528
|
+
}
|
|
529
|
+
function pageClip(input, seconds = 8) {
|
|
530
|
+
const url = siteUrl(input);
|
|
531
|
+
if (!url) return Promise.resolve({ error: "Give a page: a web address (localhost:3000 works), a host (github.com/you) or a site's name." });
|
|
532
|
+
const ms = Math.round(Math.min(CLIP.max, Math.max(CLIP.min, Number(seconds) || 8)) * 1000);
|
|
533
|
+
return oneAtATime(() => withPage(async (page) => {
|
|
534
|
+
await open(page, url);
|
|
535
|
+
const frames = [];
|
|
536
|
+
const off = page.on("Page.screencastFrame", (p) => {
|
|
537
|
+
frames.push({ ts: (p.metadata && p.metadata.timestamp) || Date.now() / 1000, jpeg: Buffer.from(p.data, "base64") });
|
|
538
|
+
page.send("Page.screencastFrameAck", { sessionId: p.sessionId }).catch(() => {});
|
|
539
|
+
});
|
|
540
|
+
try {
|
|
541
|
+
await page.send("Page.startScreencast", { format: "jpeg", quality: 88, maxWidth: VIEW.w, maxHeight: VIEW.h, everyNthFrame: 1 });
|
|
542
|
+
await evaluate(page, SCROLL_THROUGH(ms));
|
|
543
|
+
await sleep(ms);
|
|
544
|
+
await page.send("Page.stopScreencast").catch(() => {});
|
|
545
|
+
} finally { off(); }
|
|
546
|
+
const end = frames.length ? Math.max(frames[frames.length - 1].ts, frames[0].ts + ms / 1000) : 0;
|
|
547
|
+
if (!frames.length) return { error: "The page showed nothing to record." };
|
|
548
|
+
return { frames, end, url: String((await evaluate(page, "location.href")) || url), title: String((await evaluate(page, "document.title")) || "") };
|
|
549
|
+
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
550
|
+
}
|
|
551
|
+
|
|
500
552
|
// Trusted sites (Settings → Screens): on a page from one of these, press and type
|
|
501
553
|
// go ahead without asking, for you and for agents. Anywhere else each one asks.
|
|
502
554
|
// A host covers its subdomains (google.com covers mail.google.com). Only the app's
|
|
@@ -662,4 +714,4 @@ async function signIn(input) {
|
|
|
662
714
|
return { ok: true, url };
|
|
663
715
|
}
|
|
664
716
|
|
|
665
|
-
export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
|
|
717
|
+
export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
|