symbiot 0.54.3 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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.55.0
6
+
7
+ - **Away: Super+S.** Symbiot full screen while you're away from the desk: the orb at rest, the time, the agents at work and what each is doing, how many things only you can do, and how much waits on what you watch. Counts only, never anyone's words. Any key or click brings you back. On X11 with more than one screen the orb bounces across all of them; under Wayland (COSMIC, GNOME, KDE) it's one window on your screen, because no app may place windows there. `symbiot away --shortcut` sets up Super+S (on COSMIC it's done for you). Super+Esc still locks.
8
+ - **Approve no longer strands a commit.** It picked a branch name only by what's local, so a name already on GitHub (an earlier PR of that name, merged) had its push refused, and the approved work sat in a commit with no PR. Now it picks a name free on GitHub too, and if a push is still refused, it moves the commit to a fresh name and pushes that.
9
+
10
+ ## 0.54.4 — 2026-10-07
11
+
12
+ - Removed the "Add a task" row from Tasks, since you add tasks through the bottom chat bar, and moved Send to repos beside Close.
13
+ - 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.
14
+ - Moved the Reports Refresh button so it sits beside Close instead of under it.
15
+ - Added a Marketing section with post drafts, replies marked "maybe a customer", picture tools and the 4-week table, moved off the Dashboard.
16
+ - Reviewed where sandboxing for agent runs stands, with no changes to the app.
17
+ - Added marks on Connections showing which sites your agents can also use, visible before you link them.
18
+ - Changed the Dashboard to lead with what you watch; posts now appear under Marketing once drafting is possible.
19
+ - Fixed Home's right-side orbs overlapping zone names on screens 1366–1440 pixels wide.
20
+ - Added a "Pick your agent" step on first run that sets up the detected agent, such as Claude Code, in one click.
21
+
5
22
  ## 0.54.3 — 2026-10-07
6
23
 
7
24
  - Added questions on Home for stuck or failed agent runs, so you can allow access, skip, or run them again right there.
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
@@ -641,6 +643,23 @@ of this: it adapts to you.
641
643
  Everything it learns stays on this computer: `adapt.json` and `mind.json` in
642
644
  Symbiot's config folder, readable by you only.
643
645
 
646
+ ### Away: Super+S
647
+
648
+ Stepping away? **Super+S** (or `symbiot away`) puts Symbiot full screen: the orb at
649
+ rest, the time, and what's going on. That means the agents at work and what each is
650
+ doing, how many things only you can do, and how much waits on what you watch. It shows
651
+ counts only, never anyone's words, so it's fine on a screen across the room. Any key
652
+ or click (or Super+S again) brings you back.
653
+
654
+ On one screen the orb rests in the middle. On X11 desktops with more screens it
655
+ bounces across all of them, one window per screen, with the orb crossing from one to
656
+ the next. Under Wayland (COSMIC, GNOME, KDE) no app may place a window on a screen, so
657
+ it's one window on the screen you're on.
658
+
659
+ `symbiot away --shortcut` sets up Super+S. On COSMIC that's done for you (it replaces
660
+ COSMIC's own Super+S, which stacks windows); elsewhere it tells you what to bind.
661
+ Away isn't a lock: **Super+Esc** still locks the computer.
662
+
644
663
  ## Link your work
645
664
 
646
665
  **Connections** (on the Dashboard, and in Settings) has one entry per standard work site: Gmail,
@@ -685,14 +704,15 @@ release tags and dated `CHANGELOG.md` sections in the repos Symbiot already scan
685
704
  and nothing else. **It never posts by itself**, and it doesn't schedule.
686
705
 
687
706
  - **Your voice.** Put 5–10 posts you wrote in `~/.config/symbiot/voice.md`, with a
688
- line of `---` between each. Or link LinkedIn (above) and click **Fill from
689
- LinkedIn** on the Dashboard (`symbiot post voice --linkedin`), which reads your
707
+ line of `---` between each. Or link LinkedIn (above, or **Link LinkedIn** under
708
+ **Marketing**) and click **Fill from LinkedIn** there (`symbiot post voice --linkedin`), which reads your
690
709
  recent posts in Symbiot's signed-in browser. Without examples it doesn't draft.
691
710
  - **Only what git shows.** Your AI gets the week as numbered facts and must say
692
711
  which ones each post uses. A post that names a number, version, name or user
693
712
  count the facts don't show is sent back once, then dropped. When every draft is
694
713
  dropped, Symbiot tries once more from the start by itself before giving up.
695
- - **You approve each one.** The drafts wait on the **Dashboard** with **Approve**,
714
+ - **You approve each one.** The drafts wait under **Marketing**, a section of its
715
+ own (its orb shows on Home once posts can be drafted), with **Approve**,
696
716
  **Edit** and **Skip**. Approve copies the post to your clipboard and opens
697
717
  LinkedIn's share box: you paste it and post it yourself.
698
718
  - **Pictures and videos.** Each draft says in a line what picture or short video
@@ -709,10 +729,15 @@ and nothing else. **It never posts by itself**, and it doesn't schedule.
709
729
  - **Everything is logged** in `~/.config/symbiot/posts-log.jsonl`: each draft,
710
730
  edit, approval and skip, with its text, date and platform.
711
731
  - **Replies.** Linked LinkedIn is watched like an inbox: new comments and mentions
712
- show on its Dashboard card, marked **maybe a customer** when they ask how to
713
- install it, what it costs or about team use. **Draft a reply** has your agent
732
+ show under **Marketing** (and on its Dashboard card), marked **maybe a customer**
733
+ when they ask how to install it, what it costs or about team use. **Draft a reply** has your agent
714
734
  type a reply into the comment box without posting it (it can't press Post,
715
735
  Comment, Reply or Send), and write it in its brief for you to paste.
736
+ - **The 4-week test.** Marketing keeps a row a week: posts published (and how
737
+ many had a picture), replies, profile visits (LinkedIn's own count), installs
738
+ (npm downloads of the packages your posts are about) and pricing questions.
739
+ Week 1 is the first week a post goes out; to move it, put
740
+ `{ "start": "2026-10-13" }` in `~/.config/symbiot/post-test.json`.
716
741
 
717
742
  ```bash
718
743
  symbiot post # draft this week's 3 posts
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, 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 };
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, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, 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/away.mjs ADDED
@@ -0,0 +1,107 @@
1
+ // symbiot — Away: Symbiot full screen on every screen while you're away from the
2
+ // desk (Super+S, or `symbiot away`): the orb at rest, the time, and what's going on
3
+ // (agents at work and what they're doing, how much waits on you), counts only, no
4
+ // one's words. One screen: the orb in the middle. More: it bounces across all of
5
+ // them, one kiosk window per screen, each working out where the orb is from the
6
+ // same clock over the screens' shared area and drawing it while it's on that
7
+ // screen, so it crosses from one to the next with nothing passed between them.
8
+ // That needs windows placed on screens, which only X allows: under Wayland (COSMIC,
9
+ // GNOME, KDE) no app can put a window on a screen, and COSMIC ignored every way tried
10
+ // (X moves, Chrome's position flags, fullscreen on a given screen, a keypress for its
11
+ // own move-to-screen shortcut). So under Wayland it's one window, full screen where
12
+ // you are, the orb in the middle. Each window's profile lets Symbiot's address see the
13
+ // screens and go full screen without a click, so nothing asks.
14
+ // Any key or click, or Super+S again, closes every window. Not a lock: Super+Esc
15
+ // locks the computer.
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { CONFIG_DIR, chromeBinary } from "./core.mjs";
21
+
22
+ // The screens, from `xrandr --listmonitors` (X, and XWayland under COSMIC, GNOME and
23
+ // KDE): [{ x, y, w, h }], left to right. [] when it can't tell (then one window).
24
+ function parseMonitors(text) {
25
+ const out = [];
26
+ for (const m of String(text || "").matchAll(/^\s*\d+:\s+\S+\s+(\d+)\/\d+x(\d+)\/\d+\+(-?\d+)\+(-?\d+)/gm)) out.push({ w: +m[1], h: +m[2], x: +m[3], y: +m[4] });
27
+ return out.sort((a, b) => a.x - b.x || a.y - b.y);
28
+ }
29
+ function screens() {
30
+ for (const d of [process.env.DISPLAY, ":0", ":1"].filter(Boolean)) {
31
+ const r = spawnSync("xrandr", ["--listmonitors"], { encoding: "utf8", timeout: 3000, env: { ...process.env, DISPLAY: d } });
32
+ const s = r.status === 0 ? parseMonitors(r.stdout) : [];
33
+ if (s.length) return { list: s, display: d };
34
+ }
35
+ return { list: [], display: "" };
36
+ }
37
+ // Each window's place in the shared area: { x, y, w, h } plus the area's size, as
38
+ // the page's query (?away=1&ax=…&ay=…&gw=…&gh=…&n=…&t0=…).
39
+ function awayQueries(list, t0 = Date.now()) {
40
+ if (!list.length) return [`away=1&n=1&t0=${t0}`];
41
+ const x0 = Math.min(...list.map((s) => s.x)), y0 = Math.min(...list.map((s) => s.y));
42
+ const gw = Math.max(...list.map((s) => s.x + s.w)) - x0, gh = Math.max(...list.map((s) => s.y + s.h)) - y0;
43
+ return list.map((s) => `away=1&n=${list.length}&ax=${s.x - x0}&ay=${s.y - y0}&aw=${s.w}&ah=${s.h}&gw=${gw}&gh=${gh}&t0=${t0}`);
44
+ }
45
+
46
+ let KIDS = [];
47
+ const awayOpen = () => KIDS.some((k) => k.exitCode === null && !k.killed);
48
+ function closeAway() {
49
+ for (const k of KIDS) { try { process.kill(-k.pid, "SIGTERM"); } catch { try { k.kill("SIGTERM"); } catch {} } }
50
+ const n = KIDS.length; KIDS = []; return n;
51
+ }
52
+ // The two permissions an Away window needs, for the app's address only, in the
53
+ // window's own profile: the screens (window_placement) and fullscreen without a
54
+ // click (automatic_fullscreen). Chrome keeps them when it writes the file back.
55
+ function grantProfile(dir, origin) {
56
+ const f = join(dir, "Default", "Preferences"); let p = {}; try { p = JSON.parse(readFileSync(f, "utf8")) || {}; } catch {}
57
+ const ex = (((p.profile ||= {}).content_settings ||= {}).exceptions ||= {});
58
+ for (const k of ["window_placement", "automatic_fullscreen"]) (ex[k] ||= {})[`${origin},*`] = { setting: 1 };
59
+ try { mkdirSync(join(dir, "Default"), { recursive: true }); writeFileSync(f, JSON.stringify(p)); } catch {}
60
+ }
61
+ // Opens a window per screen on the app's url (http://127.0.0.1:port/?t=token), the
62
+ // i-th told it's for screen i (si). Each has a profile of its own under Symbiot's
63
+ // config, so its flags take (a window for a profile already open would join that
64
+ // browser and ignore them).
65
+ const placeable = (env = process.env) => (env.XDG_SESSION_TYPE || "").toLowerCase() === "x11" || (!env.WAYLAND_DISPLAY && !!env.DISPLAY);
66
+ function openAway(url, { chrome = chromeBinary(), scr = placeable() ? screens() : { list: [], display: "" }, now = Date.now() } = {}) {
67
+ if (!chrome) return { error: "Away needs Chrome or Chromium on this computer." };
68
+ closeAway();
69
+ const qs = awayQueries(scr.list, now);
70
+ const origin = (String(url).match(/^https?:\/\/[^/?#]+/) || [""])[0];
71
+ qs.forEach((q, i) => {
72
+ const s = scr.list[i], dir = join(CONFIG_DIR, "away", String(i)); grantProfile(dir, origin);
73
+ const args = ["--user-data-dir=" + dir, "--no-first-run", "--no-default-browser-check", "--disable-session-crashed-bubble", "--hide-crash-restore-bubble", "--noerrdialogs", "--kiosk"];
74
+ if (s) args.push(`--window-position=${s.x},${s.y}`, `--window-size=${s.w},${s.h}`); // X places it on its screen
75
+ args.push(url + (url.includes("?") ? "&" : "?") + q + `&si=${i}`);
76
+ try { const k = spawn(chrome, args, { detached: true, stdio: "ignore", env: { ...process.env, ...(s && scr.display ? { DISPLAY: scr.display } : {}) } }); k.on("exit", () => {}); k.unref(); KIDS.push(k); } catch {}
77
+ });
78
+ return { open: KIDS.length > 0, screens: Math.max(1, scr.list.length) };
79
+ }
80
+ // Open, close (open: false) or, with neither said, the other way round.
81
+ function toggleAway(url, open, deps) {
82
+ const want = open === undefined || open === null ? !awayOpen() : !!open;
83
+ if (!want) { closeAway(); return { open: false }; }
84
+ return openAway(url, deps);
85
+ }
86
+
87
+ // ---- the shortcut -------------------------------------------------------------
88
+ // Super+S runs `symbiot away`. COSMIC: a custom shortcut in its config (it reloads
89
+ // it as it changes; one for Super+S takes over the default there, which stacks
90
+ // windows). Elsewhere it says how to add one.
91
+ const COSMIC_CUSTOM = (home = homedir()) => join(home, ".config", "cosmic", "com.system76.CosmicSettings.Shortcuts", "v1", "custom");
92
+ function shortcutLine(cmd) { return ` (modifiers: [Super], key: "s"): Spawn(${JSON.stringify(cmd)}),`; }
93
+ // The new custom file: whatever was there, minus any Super+S, plus ours.
94
+ function withShortcut(text, cmd) {
95
+ const body = String(text || "").trim().replace(/^\{/, "").replace(/\}\s*$/, "");
96
+ const kept = body.split("\n").filter((l) => l.trim() && !/\(modifiers:\s*\[\s*Super\s*,?\s*\],\s*key:\s*"s"\s*\)/.test(l));
97
+ return "{\n" + [...kept.map((l) => l.replace(/\s*$/, "")), shortcutLine(cmd)].join("\n") + "\n}\n";
98
+ }
99
+ function installShortcut({ cmd, home = homedir(), desktop = process.env.XDG_CURRENT_DESKTOP || "" } = {}) {
100
+ if (!/cosmic/i.test(desktop)) return { manual: true, note: `Add a keyboard shortcut in your desktop's settings: Super+S runs ${cmd}` };
101
+ const f = COSMIC_CUSTOM(home);
102
+ let had = ""; try { had = existsSync(f) ? readFileSync(f, "utf8") : ""; } catch {}
103
+ try { mkdirSync(join(f, ".."), { recursive: true }); writeFileSync(f, withShortcut(had, cmd)); } catch (e) { return { error: "Couldn't write " + f + ": " + ((e && e.message) || e) }; }
104
+ return { ok: true, file: f, replaced: /key:\s*"s"/.test(had) };
105
+ }
106
+
107
+ export { placeable, grantProfile, parseMonitors, screens, awayQueries, openAway, closeAway, awayOpen, toggleAway, withShortcut, installShortcut, COSMIC_CUSTOM };
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/home.mjs CHANGED
@@ -14,7 +14,7 @@ import { homedir } from "node:os";
14
14
  import { readFileSync, writeFileSync } from "node:fs";
15
15
  import { watchBoard } from "./watch.mjs";
16
16
  import { pendingReview, pushTasks } from "./tasks.mjs";
17
- import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
17
+ import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent } from "./agents.mjs";
18
18
  import { linksState } from "./links.mjs";
19
19
  import { knowledgeState } from "./knowledge.mjs";
20
20
  import { CONFIG_DIR, loadTasks } from "./core.mjs";
@@ -25,6 +25,7 @@ import { resolveProvider } from "./ai.mjs";
25
25
  import { reportsNews } from "./reports.mjs";
26
26
  import { awaitingState, inboxSight } from "./handback.mjs";
27
27
  import { newClashes } from "./checks.mjs";
28
+ import { postsState } from "./post.mjs";
28
29
 
29
30
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
30
31
  let cached = null;
@@ -54,9 +55,11 @@ const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] ===
54
55
  const FAILED_FOR = 86400000, dropped = new Set();
55
56
  const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
56
57
  // 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).
58
+ // start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"), a run
59
+ // that ended waiting on you (agents.mjs needsOf: a draft for your OK, a sudo step,
60
+ // a step your answer left you) with what to check first, and a run that ended in an
61
+ // error. Each is answered on its blob (homeAnswer), on Home and on its lane in
62
+ // Tasks. repo: its lane ("" for an ops run).
60
63
  function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
61
64
  const out = [], seen = new Set();
62
65
  for (const t of stuck) {
@@ -66,9 +69,16 @@ function troubles({ stuck = [], list = [], map = {}, named = displayName, now =
66
69
  }
67
70
  for (const a of list) {
68
71
  if (seen.has(a.path)) continue; seen.add(a.path); // the newest run in each folder (agentsList: newest first)
72
+ if (a.status === "running" || (a.ask && a.ask.questions && a.ask.questions.length)) continue; // at work, or it asked: that's its question
73
+ const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, "").replace(/^\W*What['’]s needed:\W*/i, "").replace(/^./, (c) => c.toUpperCase()), 60);
74
+ const n = a.needs;
75
+ if (n && !dropped.has("needs:" + a.id)) {
76
+ const ok = n.kind === "approve";
77
+ out.push({ kind: "ask", fix: "needs", id: "needs:" + a.id, path: a.path, repo, name: nm, title: ok ? `${nm} waits for your OK` : `${nm} needs you`, sub: plain(n.label || n.what, 90),
78
+ q: n.what + (n.check ? ` Check first: ${n.check}` : ""), options: ok ? ["Go ahead (recommended)", "Skip"] : ["Done it (recommended)", "Skip"], shape: "agents" });
79
+ continue;
80
+ }
69
81
  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
82
  const said = plain(String(a.tail || "").split(/\n+/).filter((l) => l.trim()).pop() || "", 140);
73
83
  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
84
  q: `Its run stopped with an error${said ? `: ${said}` : ` (exit code ${a.exitCode})`}. Run it again?`, options: ["Run it again (recommended)", "Skip"], shape: "agents" });
@@ -96,6 +106,10 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
96
106
  const agentGone = deps.agentGone || (() => { try { return handoffCmd() ? agentMissing() : ""; } catch { return ""; } });
97
107
  const signedOut = deps.signedOut || (() => { try { const c = connectorsInfo(); return c.claude ? (c.links || []).filter((x) => x.connector && !x.ready) : []; } catch { return []; } });
98
108
  const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
109
+ const agentCmd = deps.agentCmd || (() => { try { return handoffCmd(); } catch { return "x"; } });
110
+ const offer = deps.pickAgent || (() => { try { return pickAgent(); } catch { return null; } });
111
+ // Marketing (its own section, off the Dashboard) once posts can be drafted, or there are some
112
+ const marketing = deps.marketing || (() => { try { const p = postsState(); return !!(p.canDraft || p.posts.length || p.done.length); } catch { return false; } });
99
113
  const named = deps.name || displayName;
100
114
  const you = [], map = repos() || {};
101
115
  // What Symbiot works through (an AI, your agent, the connectors runs use) shows
@@ -105,6 +119,9 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
105
119
  const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
106
120
  for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
107
121
  if (!Object.keys(map).length) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
122
+ // no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
123
+ // Home didn't say "All handled" truthfully. One click for the one on this computer.
124
+ if (!agentCmd()) { const p = offer(); you.push({ kind: "setup", id: "setup:pick", title: "Pick your agent", sub: p ? `${p.name} is on this computer: one click and it takes your tasks` : "the coding agent that takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) }); }
108
125
  for (const r of pending()) {
109
126
  if (!r.path || r.running) continue;
110
127
  const n = (r.tasks || []).length, files = (r.files || []).length;
@@ -140,17 +157,23 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
140
157
  if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
141
158
  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 } : {}) }));
142
159
  const working = list.filter((a) => a.status === "running").length;
143
- const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, at: now };
160
+ const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, marketing: marketing(), at: now };
144
161
  if (!deps.board) cached = out;
145
162
  return out;
146
163
  }
147
164
 
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 }.
165
+ // An answer on a stuck handover's, a waiting run's or a failed run's blob. pick: the
166
+ // option's place (0 goes ahead, 1 skips); text: your own words, which go ahead with
167
+ // them unless they say no. The handover's run (lanes.mjs allowHandover) reports back
168
+ // to the agent that asked; a run waiting on your OK or your step starts again with
169
+ // "go ahead" or "done" in its ANSWERS.md (skipped, it doesn't ask again); a failed
170
+ // run starts again in its folder, with your words in its ANSWERS.md. { ok, said } or { error }.
153
171
  const SAYS_NO = /^\s*(skip|no|nope|don'?t|do not|cancel|leave it|never ?mind|stop)\b/i;
172
+ // An answer, in the folder's ANSWERS.md, for the run that picks it up.
173
+ function noteAnswer(path, heading, text) {
174
+ const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
175
+ try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### ${String(heading).replace(/\s+/g, " ").trim()}\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
176
+ }
154
177
  function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
155
178
  id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
156
179
  const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
@@ -164,11 +187,22 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
164
187
  const a = (deps.agents || agentsList)().find((x) => x.id === ref); if (!a) return { error: "That run isn't here any more." };
165
188
  if (!go) { dropped.add(ref); return { ok: true, said: "Skipped." }; }
166
189
  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 {} }
190
+ if (text) noteAnswer(a.path, "Your last run stopped with an error", text);
169
191
  const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
170
192
  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
193
  }
194
+ if (id.startsWith("needs:")) {
195
+ const a = (deps.agents || agentsList)().find((x) => x.id === ref), n = a && a.needs;
196
+ if (!n) return { error: "That isn't waiting on you any more." };
197
+ const settle = deps.settle || settleNeeds;
198
+ if (!go) { settle(a.path, n.key); dropped.add(id); return { ok: true, said: "Skipped. It won't ask again." }; }
199
+ if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
200
+ const said = n.kind === "approve" ? `Go ahead: ${text || "do it as you had it, once you've checked what you flagged"}.` : `Done: the user did it${text ? ` (${text})` : ""}. Check it worked, then carry on.`;
201
+ noteAnswer(a.path, n.what, said);
202
+ const e = (deps.run || runHandoff)(a.path, { force: true });
203
+ if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
204
+ return e && e.id && !e.blocked ? { ok: true, said: n.kind === "approve" ? "Its agent is going ahead." : "Its agent is checking and carrying on.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
205
+ }
172
206
  return { error: "Answer that one on its block in Agents." };
173
207
  }
174
208
 
package/index.mjs CHANGED
@@ -328,6 +328,20 @@ async function viaApp(path, body) {
328
328
  return r.ok ? await r.json() : { error: `Symbiot's app answered ${r.status}.` };
329
329
  } catch (e) { return { error: "Lost Symbiot's app mid-way: " + ((e && e.message) || e) }; }
330
330
  }
331
+ // symbiot away: Symbiot full screen on every screen (again: closes it). --shortcut:
332
+ // Super+S runs it (on COSMIC it's set up; elsewhere it says how).
333
+ async function cmdAway() {
334
+ if (has("shortcut")) {
335
+ const { installShortcut } = await import("./away.mjs");
336
+ const r = installShortcut({ cmd: `${process.execPath} ${process.argv[1]} away` });
337
+ if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; return; }
338
+ console.log(r.manual ? r.note : `${c.g("✓")} Super+S now opens Away${r.replaced ? " (it replaces what Super+S did before)" : ""}. ${c.d(r.file)}`);
339
+ return;
340
+ }
341
+ const r = await viaApp("/api/away", {});
342
+ if (!r) { console.log(c.y("Symbiot's app isn't running.") + c.d(" Start it with symbiot app, then Away works.")); process.exitCode = 1; return; }
343
+ if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; }
344
+ }
331
345
  async function cmdScreens() {
332
346
  const [sub = "list", a1, a2, a3] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--name");
333
347
  const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
@@ -636,7 +650,7 @@ async function cmdPost() {
636
650
  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
651
  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>).`));
638
652
  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("; "));
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.`));
653
+ 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 under Marketing in the app too.`));
640
654
  console.log(c.d("Nothing is posted until you approve one: symbiot post approve <id> (or edit / skip)."));
641
655
  return;
642
656
  }
@@ -753,6 +767,8 @@ ${c.b("Usage")}
753
767
  symbiot standup yesterday + today, for standup
754
768
  symbiot todo what's still on your plate
755
769
  symbiot app open the visual app in your browser
770
+ symbiot away [--shortcut] Symbiot full screen on every screen while you're away;
771
+ again closes it. --shortcut: Super+S does it
756
772
  symbiot app --fresh [--keep] try it as someone new: a brand-new Symbiot in
757
773
  a throwaway home, none of your data or accounts
758
774
  symbiot drift [--fetch] what's out of sync / at risk across repos
@@ -808,6 +824,7 @@ async function main() {
808
824
  if (cmd === "logout") return cmdLogout();
809
825
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
810
826
  if (cmd === "app" || cmd === "ui") return cmdApp();
827
+ if (cmd === "away") return cmdAway();
811
828
  if (cmd === "models" || cmd === "hardware") return cmdModels();
812
829
  if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
813
830
  if (cmd === "drift") return cmdDrift();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.54.3",
3
+ "version": "0.55.0",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,8 @@
42
42
  "LICENSE",
43
43
  "fonts",
44
44
  "mapknn.mjs",
45
- "guard.mjs"
45
+ "guard.mjs",
46
+ "away.mjs"
46
47
  ],
47
48
  "engines": {
48
49
  "node": ">=18"
@@ -75,7 +76,7 @@
75
76
  "@anthropic-ai/sdk": "^0.128.0"
76
77
  },
77
78
  "scripts": {
78
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
79
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
79
80
  "lint": "eslint ."
80
81
  },
81
82
  "devDependencies": {