symbiot 0.54.2 → 0.54.3

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,16 @@
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.3 — 2026-10-07
6
+
7
+ - Added questions on Home for stuck or failed agent runs, so you can allow access, skip, or run them again right there.
8
+ - Added a ready picture to post drafts whose idea is a screen of an app you run locally; keep it or remove it.
9
+ - Changed post drafting to try once more by itself when every draft gets dropped for claiming things git doesn't show.
10
+ - Added a separate sandbox for trying Symbiot's first-run setup without touching your real settings, drafts or files.
11
+ - Fixed Orca losing track of a repo when a handover renames its folder; Symbiot now adds it again at the new location.
12
+ - Fixed reply tracking treating a signed-out inbox as watched; Home now asks you to sign in again so replies aren't missed.
13
+ - Added a "Where your files disagree" alert under Watching on Home when your files contradict each other on something important.
14
+
5
15
  ## 0.54.2
6
16
 
7
17
  - **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
@@ -387,7 +387,10 @@ preset only fills in the command box; the saved command is what runs.
387
387
  - **Orca IDE** (any OS): opens the repo in Orca — either just the repo, to use
388
388
  Orca's own agent, or with Claude running in a new tab (allowed `npm test` and
389
389
  `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.
390
+ it's closed and waits for it to be ready first. When a handover renames a
391
+ repo's folder, Symbiot adds it to Orca again at its new path (matched by its
392
+ GitHub remote, else its name). Orca keeps the old entry, marked missing, until
393
+ you remove it there.
391
394
  - **Editors — opens only, no review:** VS Code, Cursor, Windsurf, Zed, Sublime
392
395
  Text, IntelliJ IDEA, Neovim (on macOS, also found as `.app` bundles when the CLI
393
396
  isn't on your PATH). They just open the repo. Nothing comes back for review unless
@@ -575,6 +578,15 @@ and the blob dissolves. More than fit across go on a second page. Clicking the o
575
578
  questions and its Approve under **Needs you here** at the top of its tasks, with its
576
579
  handovers that are waiting; an ops run's question on its block in Agents.
577
580
 
581
+ What stopped and can't go on without you is a question there too, on its lane.
582
+ A handover that couldn't start (an ops run, limited to its own folder, handed
583
+ "needs a run that can edit `~/Company`" to ops) asks **Allow this run access to
584
+ ~/Company?**: **Allow** starts a run of its own on it, allowed to read, edit and
585
+ move files in that folder (that run only), and what it does goes back to the agent
586
+ that asked; **Skip** lets it go, and that agent reads that you skipped it. It asks
587
+ for 3 days, or until the same thing goes through another way. A run that ended in
588
+ an error asks **Run it again?** for a day, until another run starts there.
589
+
578
590
  Under the band, around the core, are the parts of the app, grouped the
579
591
  **P.A.R.A.** way, each group a row with its name at its start, always in the same
580
592
  place and order:
@@ -678,10 +690,22 @@ and nothing else. **It never posts by itself**, and it doesn't schedule.
678
690
  recent posts in Symbiot's signed-in browser. Without examples it doesn't draft.
679
691
  - **Only what git shows.** Your AI gets the week as numbered facts and must say
680
692
  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.
693
+ count the facts don't show is sent back once, then dropped. When every draft is
694
+ dropped, Symbiot tries once more from the start by itself before giving up.
682
695
  - **You approve each one.** The drafts wait on the **Dashboard** with **Approve**,
683
696
  **Edit** and **Skip**. Approve copies the post to your clipboard and opens
684
697
  LinkedIn's share box: you paste it and post it yourself.
698
+ - **Pictures and videos.** Each draft says in a line what picture or short video
699
+ would show it best. Add one of yours (**Add picture or video**: PNG, JPG, GIF,
700
+ MP4, MOV, WebM), or click **From a page** and give a web address (your app on
701
+ `localhost` works) to **Take a picture** of it, sharp enough for a feed, or
702
+ **Record a clip**: 5–30 seconds of it, scrolling slowly down, as an MP4 (needs
703
+ `ffmpeg`). A post takes a video or up to 20 pictures, as LinkedIn does. They're
704
+ copies, kept in `~/.config/symbiot/post-media/`; Approve opens their folder for
705
+ you to add them with LinkedIn's photo or video button. A draft whose idea is a
706
+ screen of an app you run here (listening on `localhost`, started in that repo's
707
+ folder, or Symbiot's own app: "the new Reports view" opens Reports) arrives with
708
+ its picture already on it: keep it or remove it.
685
709
  - **Everything is logged** in `~/.config/symbiot/posts-log.jsonl`: each draft,
686
710
  edit, approval and skip, with its text, date and platform.
687
711
  - **Replies.** Linked LinkedIn is watched like an inbox: new comments and mentions
@@ -695,6 +719,9 @@ symbiot post # draft this week's 3 posts
695
719
  symbiot post list # the drafts waiting on you
696
720
  symbiot post approve <id> # copy it, and get LinkedIn's share box (--open opens it)
697
721
  symbiot post edit <id> "text" # change a draft's words
722
+ symbiot post add <id> <file> # put a picture or video of yours on it
723
+ symbiot post page <id> <page> # a picture of a web page on it (--clip: a short video)
724
+ symbiot post remove <id> <m> # take a picture or video off it
698
725
  symbiot post skip <id> # drop one
699
726
  symbiot post log # everything that happened
700
727
  ```
@@ -765,7 +792,9 @@ Every brief also tells the agent what else it can leave in `.symbiot/`:
765
792
  gets it as a task and its agent starts, told what was asked for and what to do
766
793
  next. The task it's for says so in its chat, and you get a notification. The
767
794
  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.
795
+ the reply leaves for you to decide comes back as that agent's question. While
796
+ one waits and nothing can see the reply (no inbox watched and Email off, or the
797
+ watched inbox signed out), Home and the Dashboard say so.
769
798
  - **A report** (findings, an audit, a plan, a pitch): any `.md` named for what it
770
799
  is, with a `# ` title. **Reports** in the app lists every one your runs left,
771
800
  in your repos and in runs of their own, newest first, unread ones marked; click
@@ -861,6 +890,8 @@ CSV and Markdown tables, front matter and "Owner:" lines, and examples are never
861
890
  used as facts. The list, most pressing first and each with its files, is under
862
891
  **Settings → Knowledge folders**; it's checked again whenever a file changes, and
863
892
  **Check again** does it now. It's kept in `~/.config/symbiot/checks.json`, yours only.
893
+ A clash that matters (a deadline on someone's leave or a holiday, a renewal or
894
+ price told two ways) also shows on Home, under Watching, until you open it.
864
895
 
865
896
  - **Examples aren't facts.** Say which paths in a folder hold worked examples
866
897
  (`--examples templates/,active/`, or the second box in Settings; `templates/`
package/agents.mjs CHANGED
@@ -885,8 +885,8 @@ function agentsList() {
885
885
  const { tail, work, progress } = workOf(e.path, readRunLog(e.log));
886
886
  const first = !seen.has(e.path); seen.add(e.path);
887
887
  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 };
888
+ 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: first && e.status !== "running" ? waitingFor(e.path) : null, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
889
889
  }).concat(earlierRuns());
890
890
  }
891
891
 
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 };
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, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , 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/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 };
package/home.mjs CHANGED
@@ -4,24 +4,27 @@
4
4
  // folders), always out front;
5
5
  // - feeds: what's new on the sites you watch (watch.mjs), sized by how much;
6
6
  // - lanes: work handed between agents (lanes.mjs), the latest few.
7
- // Reports agents wrote up that you haven't read (reports.mjs) are a feed too.
7
+ // Reports agents wrote up that you haven't read (reports.mjs) are a feed too, and
8
+ // so is a clash in your company folder that matters (checks.mjs newClashes).
8
9
  // The app's sections (Map, Tasks, Agents, Week…) are shaped by adapt.mjs from
9
10
  // how you use them; this is the live data around them. Home's talk goes to the
10
11
  // same Symbiot as every chat (mind.mjs converse), told what home shows.
11
12
  import { join } from "node:path";
12
- import { readFileSync } from "node:fs";
13
- import { watchBoard, watchState, isMail } from "./watch.mjs";
13
+ import { homedir } from "node:os";
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+ import { watchBoard } from "./watch.mjs";
14
16
  import { pendingReview, pushTasks } from "./tasks.mjs";
15
- import { agentsList, runHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
17
+ import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
16
18
  import { linksState } from "./links.mjs";
17
19
  import { knowledgeState } from "./knowledge.mjs";
18
- import { CONFIG_DIR, loadConfig, loadTasks } from "./core.mjs";
19
- import { lanesState } from "./lanes.mjs";
20
+ import { CONFIG_DIR, loadTasks } from "./core.mjs";
21
+ import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
20
22
  import { converse, actIn, actNow, taskIn } from "./mind.mjs";
21
23
  import { repoPathMap } from "./scan.mjs";
22
24
  import { resolveProvider } from "./ai.mjs";
23
25
  import { reportsNews } from "./reports.mjs";
24
- import { awaitingState } from "./handback.mjs";
26
+ import { awaitingState, inboxSight } from "./handback.mjs";
27
+ import { newClashes } from "./checks.mjs";
25
28
 
26
29
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
27
30
  let cached = null;
@@ -46,6 +49,32 @@ function displayName(path, folder) {
46
49
  // The lane a folder is: the repo it is (by the scan's map), or an ops run.
47
50
  const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] === path) || (String(path || "").startsWith(RUNS) ? "" : null);
48
51
 
52
+ // A run that failed shows for a day, until you run it again or skip it; one
53
+ // that's been followed by another run in its folder is past.
54
+ const FAILED_FOR = 86400000, dropped = new Set();
55
+ const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
56
+ // What stopped and can't go on without you, as questions: a handover that couldn't
57
+ // start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"), and a run
58
+ // that ended in an error. Each is answered on its blob (homeAnswer), on Home and on
59
+ // its lane in Tasks. repo: its lane ("" for an ops run).
60
+ function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
61
+ const out = [], seen = new Set();
62
+ for (const t of stuck) {
63
+ const lane = t.from.lane === RUNS_LANE ? "" : t.from.lane;
64
+ out.push({ kind: "ask", fix: "handover", id: "stuck:" + t.id, path: lane ? map[lane] || t.from.path : t.from.path, repo: lane, name: lane ? named(map[lane], lane) : "Agent runs",
65
+ title: `${lane || "An agent run"} is stuck`, sub: plain(firstLine(t.text), 90), q: t.q, options: t.options, why: plain(t.error, 160), shape: "agents" });
66
+ }
67
+ for (const a of list) {
68
+ if (seen.has(a.path)) continue; seen.add(a.path); // the newest run in each folder (agentsList: newest first)
69
+ if (a.status !== "failed" || dropped.has(a.id) || (a.endedAt && now - a.endedAt > FAILED_FOR)) continue;
70
+ if (a.ask && a.ask.questions && a.ask.questions.length) continue; // it asked: that's its question
71
+ const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60);
72
+ const said = plain(String(a.tail || "").split(/\n+/).filter((l) => l.trim()).pop() || "", 140);
73
+ out.push({ kind: "ask", fix: "failed", id: "failed:" + a.id, path: a.path, repo, name: nm, title: `${nm}'s run stopped`, sub: said || `it exited with code ${a.exitCode}`,
74
+ q: `Its run stopped with an error${said ? `: ${said}` : ` (exit code ${a.exitCode})`}. Run it again?`, options: ["Run it again (recommended)", "Skip"], shape: "agents" });
75
+ }
76
+ return out;
77
+ }
49
78
  // { you: [{ kind, id, title, sub, shape, … }], feeds: [{ id, title, sub, count, shape }],
50
79
  // lanes: [{ from, to, text, status, times }], working: n, at }
51
80
  // An ask carries what its blob on home needs: the lane (repo, or "" for an ops
@@ -60,11 +89,13 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
60
89
  const connected = deps.connected || (() => !!resolveProvider());
61
90
  const repos = deps.repos || (() => { try { return repoPathMap(); } catch { return {}; } });
62
91
  const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
63
- // can a reply be noticed? Email on (mail on this computer), or an inbox watched
64
- const seesInbox = deps.seesInbox || (() => { try { return !!(loadConfig().mail || {}).enabled || watchState().watches.some((w) => isMail(w.url)); } catch { return true; } });
92
+ const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
93
+ // can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
94
+ const seesInbox = deps.seesInbox || (() => { try { return inboxSight(); } catch { return true; } });
65
95
  // the agent the handoff command runs, gone from this computer; Claude connectors linked here but signed out
66
96
  const agentGone = deps.agentGone || (() => { try { return handoffCmd() ? agentMissing() : ""; } catch { return ""; } });
67
97
  const signedOut = deps.signedOut || (() => { try { const c = connectorsInfo(); return c.claude ? (c.links || []).filter((x) => x.connector && !x.ready) : []; } catch { return []; } });
98
+ const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
68
99
  const named = deps.name || displayName;
69
100
  const you = [], map = repos() || {};
70
101
  // What Symbiot works through (an AI, your agent, the connectors runs use) shows
@@ -89,10 +120,22 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
89
120
  you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
90
121
  title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), q: String(q.q || ""), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), shape: "agents" });
91
122
  }
123
+ // What stopped and can't go on without you, as a question too: a handover that
124
+ // couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
125
+ // and a run that ended in an error. Answered on its blob (homeAnswer).
126
+ you.push(...troubles({ stuck: stuck(), list, map, named, now }));
92
127
  // A run is waiting on an emailed reply that nothing will see: say so, once,
93
- // until an inbox is watched or Email is on (handback.mjs looks in either).
94
- if (waits().some((w) => w.status === "waiting") && !seesInbox()) you.push({ kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
128
+ // until an inbox is watched (and signed in) or Email is on (handback.mjs looks in either).
129
+ if (waits().some((w) => w.status === "waiting")) {
130
+ const s = seesInbox(), sight = s && typeof s === "object" ? s : { sees: !!s, signedOut: [] };
131
+ if (!sight.sees) you.push(sight.signedOut.length
132
+ ? { kind: "setup", id: "setup:inbox", signin: true, title: `Sign in to ${sight.signedOut[0]} again`, sub: "it's signed out, so I won't notice their reply", shape: "settings", focus: "links" }
133
+ : { kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
134
+ }
95
135
  const feeds = ((board() || {}).cards || []).filter((c) => c.count > 0).map((c) => ({ id: "feed:" + c.id, title: c.name, sub: c.label, count: c.count, shape: "board" }));
136
+ // a clash in your files that matters (checks.mjs: high), first under Watching, until you've opened it
137
+ const clash = clashes();
138
+ if (clash.length) feeds.unshift({ id: "feed:clash", title: "Where your files disagree", sub: `${clash.length} clash${clash.length > 1 ? "es" : ""} to look at`, latest: plain(clash[0].text, 140), count: clash.length, shape: "settings" });
96
139
  const rep = reports();
97
140
  if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
98
141
  const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status, ...(h.times > 1 ? { times: h.times } : {}) }));
@@ -102,6 +145,33 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
102
145
  return out;
103
146
  }
104
147
 
148
+ // An answer on a stuck handover's or a failed run's blob. pick: the option's place
149
+ // (0 goes ahead, 1 skips); text: your own words, which go ahead with them unless
150
+ // they say no. The handover's run (lanes.mjs allowHandover) reports back to the
151
+ // agent that asked; a failed run starts again in its folder, with your words in its
152
+ // ANSWERS.md. { ok, said } or { error }.
153
+ const SAYS_NO = /^\s*(skip|no|nope|don'?t|do not|cancel|leave it|never ?mind|stop)\b/i;
154
+ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
155
+ id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
156
+ const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
157
+ cached = null;
158
+ if (id.startsWith("stuck:")) {
159
+ if (!go) { const r = (deps.skip || skipHandover)(ref); return r.error ? r : { ok: true, said: "Skipped. Its agent knows." }; }
160
+ const r = (deps.allow || allowHandover)(ref, { note: text }); if (r.error) return r;
161
+ return { ok: true, said: `Started a run on it${r.dirs && r.dirs.length ? `, allowed into ${r.dirs.map((d) => (d.startsWith(homedir() + "/") ? "~" + d.slice(homedir().length) : d)).join(" and ")}` : ""}. What it does goes back to the agent that asked.` };
162
+ }
163
+ if (id.startsWith("failed:")) {
164
+ const a = (deps.agents || agentsList)().find((x) => x.id === ref); if (!a) return { error: "That run isn't here any more." };
165
+ if (!go) { dropped.add(ref); return { ok: true, said: "Skipped." }; }
166
+ if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
167
+ if (text) { const f = join(a.path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
168
+ try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### Your last run stopped with an error\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {} }
169
+ const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
170
+ return e && e.id && !e.blocked ? { ok: true, said: "Running it again.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
171
+ }
172
+ return { error: "Answer that one on its block in Agents." };
173
+ }
174
+
105
175
  // Settings' first steps, in the order they matter (FIRST-RUN-AUDIT.md: a new user
106
176
  // found Settings one long page with nothing saying what comes first). Each is
107
177
  // done or not from what's set up, so it ticks itself; the company folder is
@@ -136,7 +206,7 @@ function firstSteps({ deps = {} } = {}) {
136
206
  // One line per thing home shows, for the talk's context.
137
207
  function homeContext(h) {
138
208
  return [
139
- `Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}`),
209
+ `Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}${y.q && y.q !== y.sub ? ` (asks: ${y.q}${(y.options || []).length ? ` ${y.options.join(" / ")}` : ""})` : ""}`),
140
210
  `New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}${f.latest ? ` (latest: ${f.latest})` : ""}`) : ["- nothing new"]),
141
211
  `Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
142
212
  ].join("\n");
@@ -218,6 +288,11 @@ function workScene({ deps = {} } = {}) {
218
288
  for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path }); }
219
289
  for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
220
290
  }
291
+ const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
292
+ for (const t of troubles({ stuck: stuck(), list: AG, map, named })) {
293
+ const p = at(t.repo || RUNS_LANE); p.asks++; p.qs = p.qs || [];
294
+ if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub });
295
+ }
221
296
  for (const p of Object.values(by)) { p.lit = !!(p.asks || p.ready); if (isParked(p.repo)) p.parked = true; }
222
297
  const projects = Object.values(by).sort((a, b) => (b.lit ? 1 : 0) - (a.lit ? 1 : 0) || (a.parked ? 1 : 0) - (b.parked ? 1 : 0) || (b.running ? 1 : 0) - (a.running ? 1 : 0) || b.ready - a.ready || b.waiting - a.waiting || b.last - a.last || (a.repo < b.repo ? -1 : 1));
223
298
  return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy && !w.parked).length, projects: projects.slice(0, 24), projectCount: projects.length };
@@ -234,4 +309,4 @@ function workGo({ push = pushTasks, run = runHandoff } = {}) {
234
309
  return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
235
310
  }
236
311
 
237
- export { homeState, homeContext, homeAsk, workScene, workGo, displayName, firstSteps };
312
+ export { homeState, homeContext, homeAsk, homeAnswer, workScene, workGo, displayName, firstSteps };
package/index.mjs CHANGED
@@ -25,8 +25,8 @@
25
25
 
26
26
  import { spawn } from "node:child_process";
27
27
  import { homedir } from "node:os";
28
- import { join } from "node:path";
29
- import { realpathSync } from "node:fs";
28
+ import { join, basename } from "node:path";
29
+ import { realpathSync, readFileSync } from "node:fs";
30
30
  import { createInterface } from "node:readline";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import { EMBEDDED_UI } from "./ui.mjs";
@@ -41,7 +41,7 @@ import { loadScreens, screenImage, blueprint } from "./screens.mjs";
41
41
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
42
42
  import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
43
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
44
- import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl } from "./post.mjs";
44
+ import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
45
45
  import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
46
46
  import { listReports, readReport } from "./reports.mjs";
47
47
  import { runSandbox } from "./sandbox.mjs";
@@ -621,16 +621,20 @@ async function cmdPhone() {
621
621
  // Nothing is ever posted by Symbiot: approve copies one post to your clipboard and
622
622
  // gives LinkedIn's share box, where you paste it and post it yourself.
623
623
  async function cmdPost() {
624
- const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since");
624
+ const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since" && all[i - 1] !== "--seconds");
625
625
  const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
626
626
  const show = (p) => {
627
627
  console.log(`\n${c.b(POST_KIND[p.kind] || p.kind)} ${c.d(`${p.id} · ${p.status}${p.edited ? " · edited" : ""}`)}\n${p.text}`);
628
628
  if (p.sources && p.sources.length) console.log(c.d(" from git: " + p.sources.slice(0, 4).map((s) => s.replace(/^\[\d+\]\s*/, "")).join("\n ") + (p.sources.length > 4 ? `\n …and ${p.sources.length - 4} more` : "")));
629
+ for (const m of p.media || []) console.log(c.d(` ${m.kind} ${m.id}: `) + join(postMediaDir(p.id), m.file) + c.d(m.url ? ` (${m.from === "clip" ? "clip" : "picture"} of ${m.url})` : ""));
630
+ if (p.show && !(p.media || []).length) console.log(c.d(" picture idea: " + p.show));
629
631
  };
630
632
  if (sub === "draft" || sub === "new") {
631
633
  const r = await draftPosts({ days: SINCE_WEEK || 7 });
632
634
  if (r.error) return fail(r.error);
633
635
  for (const p of r.posts) show(p);
636
+ if (r.retried) console.log("\n" + c.d("The first try kept none (each claimed what git doesn't show), so Symbiot tried again by itself."));
637
+ if (r.pictures) console.log(c.d(`${r.pictures === 1 ? "One draft names" : `${r.pictures} drafts name`} a screen of an app you run here, so ${r.pictures === 1 ? "it has its picture" : "they have their pictures"} already: keep or remove (symbiot post remove <id> <media id>).`));
634
638
  if (r.dropped.length) console.log("\n" + c.y(`Dropped ${r.dropped.length}: `) + r.dropped.map((x) => `${POST_KIND[x.kind] || x.kind} (${x.cited ? "claimed " + x.unsupported.join(", ") + ", which git doesn't show" : "cited nothing from git"})`).join("; "));
635
639
  console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait on the app's Dashboard too.`));
636
640
  console.log(c.d("Nothing is posted until you approve one: symbiot post approve <id> (or edit / skip)."));
@@ -648,10 +652,33 @@ async function cmdPost() {
648
652
  console.log(`${c.g("✓")} Approved.\n\n${r.post.text}\n`);
649
653
  console.log(r.copied ? `${c.g("✓")} Copied to your clipboard (${r.copied}).` : c.y("No clipboard tool found (wl-copy, xclip, xsel, pbcopy or clip): copy it from above."));
650
654
  console.log(`Paste it into LinkedIn's share box: ${c.b(r.share)}` + (has("open") ? "" : c.d(" (--open opens it)")));
651
- if (has("open")) openUrl(r.share);
655
+ if (r.files.length) console.log(`Then add ${r.files.length === 1 ? "this" : "these"} with its photo or video button:\n${r.files.map((f) => " " + f).join("\n")}` + (has("open") ? "" : c.d(" (--open opens their folder)")));
656
+ if (has("open")) { openUrl(r.share); if (r.folder) openUrl(r.folder); }
652
657
  console.log(c.d(r.note));
653
658
  return;
654
659
  }
660
+ // pictures and video on a draft: a file of yours, or a picture or clip of a page
661
+ if (sub === "add") {
662
+ if (!a2) return fail("Give the draft and the file: symbiot post add <id> <picture or video>");
663
+ let data; try { data = readFileSync(a2); } catch (e) { return fail(`Can't read ${a2}: ${e.message}`); }
664
+ const r = addPostMedia(a1, data, { name: basename(a2) });
665
+ if (r.error) return fail(r.error);
666
+ show(r.post); return;
667
+ }
668
+ if (sub === "page") {
669
+ if (!a2) return fail("Give the draft and the page: symbiot post page <id> <web address> [--clip] [--seconds 8]");
670
+ const clip = has("clip"), i = argv.indexOf("--seconds"), seconds = i >= 0 ? Number(argv[i + 1]) || 8 : 8;
671
+ console.log(c.d(clip ? `Recording ${seconds} s of ${a2}…` : `Taking a picture of ${a2}…`));
672
+ // through the app when it runs: its hidden browser and this one share a profile
673
+ const r = (await viaApp("/api/posts/media/page", { id: a1, url: a2, clip, seconds })) || await (clip ? clipOfPage(a1, a2, { seconds }) : pictureOfPage(a1, a2));
674
+ if (r.error) return fail(r.error);
675
+ show(r.post); return;
676
+ }
677
+ if (sub === "unadd" || sub === "remove") {
678
+ const r = removePostMedia(a1, a2);
679
+ if (r.error) return fail(r.error);
680
+ console.log(`${c.g("✓")} Taken off ${r.post.id}.`); return;
681
+ }
655
682
  if (sub === "edit") {
656
683
  if (!a2) return fail('Give the new text: symbiot post edit <id> "the post"');
657
684
  const r = editPost(a1, a2);
@@ -685,6 +712,14 @@ async function cmdPost() {
685
712
  copy it to your clipboard, and give (--open:
686
713
  open) LinkedIn's share box to paste it into
687
714
  symbiot post edit <id> "text" change a draft's words
715
+ symbiot post add <id> <file> put a picture or video of yours on a draft
716
+ (PNG, JPG, GIF, MP4, MOV, WebM)
717
+ symbiot post page <id> <page> [--clip] [--seconds 8]
718
+ a picture of a web page (localhost too) on a
719
+ draft; --clip records a short video of it,
720
+ scrolling down (needs ffmpeg)
721
+ symbiot post remove <id> <media id>
722
+ take a picture or video off a draft
688
723
  symbiot post skip <id> drop a draft
689
724
  symbiot post log everything drafted, edited, approved, skipped
690
725
  symbiot post voice [--linkedin] your example posts (voice.md), or read your