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 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, …). Filter by
97
- type or repo, then **Send to repos** to hand just those to your agent (see
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) and click **Fill from
677
- LinkedIn** on the Dashboard (`symbiot post voice --linkedin`), which reads your
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
- - **You approve each one.** The drafts wait on the **Dashboard** with **Approve**,
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** when they ask how to
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 tail = "", work = null, progress = null, end = Date.now(); try { ({ tail, work, progress } = workOf(r.path, readRunLog(log))); if (!busy) end = statSync(log).mtimeMs; } catch {}
277
- 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, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, remember: facts.length ? facts : null, fromHeld: false });
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
- return { id: e.id, name: e.name, path: e.path, status: e.status, 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: first && e.status !== "running" ? waitingFor(e.path) : null, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
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
- // The Dashboard's list: what's still waiting, then what came in lately, newest first.
167
- function awaitingState() {
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: [...all.filter((w) => w.status === "waiting"), ...all.filter((w) => w.status !== "waiting" && w.status !== "stopped").slice(0, 10)].map(view) };
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
- b.page = { send, until, idle };
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 };