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 +17 -0
- package/README.md +32 -7
- package/adapt.mjs +1 -1
- package/agents.mjs +62 -6
- package/away.mjs +107 -0
- package/handover.mjs +41 -2
- package/home.mjs +48 -14
- package/index.mjs +18 -1
- package/package.json +4 -3
- package/post.mjs +68 -10
- package/server.mjs +15 -4
- package/tasks.mjs +11 -2
- package/ui.mjs +174 -44
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, …).
|
|
97
|
-
|
|
96
|
+
- **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). You add
|
|
97
|
+
a task by telling Symbiot in the bar at the bottom: it files it under the right
|
|
98
|
+
project. Filter by type or repo, then **Send to repos** (at the top, beside
|
|
99
|
+
Close) to hand just those to your agent (see
|
|
98
100
|
[`symbiot push`](#hand-tasks-to-your-coding-agent-symbiot-push)). What your agent
|
|
99
101
|
finishes lands in **Awaiting your review** (see
|
|
100
102
|
[Review and approve](#review-and-approve-the-agents-work)). A task you tick
|
|
@@ -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
|
|
689
|
-
|
|
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
|
|
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**
|
|
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
|
|
277
|
-
|
|
286
|
+
let end = Date.now(); if (!busy) try { end = statSync(log).mtimeMs; } catch {}
|
|
287
|
+
const needs = busy ? null : needsOf(r.path, { final: lastFinal(r.path), waiting: wait, end });
|
|
288
|
+
if (!busy && !ask.questions.length && !wait && !facts.length && !needs) continue;
|
|
289
|
+
let tail = "", work = null, progress = null; try { ({ tail, work, progress } = workOf(r.path, readRunLog(log))); } catch {}
|
|
290
|
+
out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, startedAt, endedAt: busy ? null : end, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, needs, remember: facts.length ? facts : null, fromHeld: false });
|
|
278
291
|
}
|
|
279
292
|
return out;
|
|
280
293
|
}
|
|
281
294
|
|
|
295
|
+
// ---- what a finished run left to you ----------------------------------------
|
|
296
|
+
// A run that ended saying something waits on you (handover.mjs leftToYou: "the
|
|
297
|
+
// draft isn't sent", "needs sudo, and only you can do that"), or waiting on a step
|
|
298
|
+
// an answer of yours picked (waitingFor), needs you, and says so: on its block in
|
|
299
|
+
// Agents and on Home, with what to check first. It did end, so before this nothing
|
|
300
|
+
// showed it: Home said nothing needed you while a reply to Cale sat in Drafts. It
|
|
301
|
+
// stays for NEEDS_FOR, until you go ahead or skip it (settled.json keeps which, per
|
|
302
|
+
// folder), a newer run there says something else, or a newer ops run took up the
|
|
303
|
+
// same task. { kind: "approve"|"step", what, check, label, key, waiting? } or null.
|
|
304
|
+
const NEEDS_FOR = 3 * 86400000, SETTLED = join(CONFIG_DIR, "settled.json"), ACT_DIR = join(CONFIG_DIR, "drafts") + "/";
|
|
305
|
+
const loadSettled = () => { try { const o = JSON.parse(readFileSync(SETTLED, "utf8")); return o && typeof o === "object" && !Array.isArray(o) ? o : {}; } catch { return {}; } };
|
|
306
|
+
function settleNeeds(path, key) {
|
|
307
|
+
const o = loadSettled(); o[path] = String(key || "");
|
|
308
|
+
try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(SETTLED, JSON.stringify(o, null, 2), { mode: 0o600 }); } catch {}
|
|
309
|
+
}
|
|
310
|
+
// A streaming run's last words (its final answer), read once per change of its log.
|
|
311
|
+
const FINALS = new Map();
|
|
312
|
+
function lastFinal(path) {
|
|
313
|
+
const log = join(path, ".symbiot", "agent.log"); let m = 0; try { m = statSync(log).mtimeMs; } catch { return ""; }
|
|
314
|
+
const c = FINALS.get(path); if (c && c.m === m) return c.final;
|
|
315
|
+
let final = ""; try { const w = parseRun(lastRunText(readRunLog(log))); final = w.stream ? w.final : ""; } catch {}
|
|
316
|
+
FINALS.set(path, { m, final }); return final;
|
|
317
|
+
}
|
|
318
|
+
const firstTask = (path) => (readSymbiot(path, "TASKS.md").match(/^\s*-\s*\[[ x]\]\s*(.+)$/im) || [])[1] || "";
|
|
319
|
+
// an ops run (a folder of its own each time) whose task a newer ops run took up
|
|
320
|
+
function takenUp(path, end) {
|
|
321
|
+
if (!path.startsWith(ACT_DIR)) return false;
|
|
322
|
+
const t = firstTask(path); return !!t && loadRuns().some((r) => r.path !== path && r.path.startsWith(ACT_DIR) && Number(r.startedAt) > end && sameTask(firstTask(r.path), t));
|
|
323
|
+
}
|
|
324
|
+
function needsOf(path, { final = "", waiting = null, end = Date.now(), now = Date.now() } = {}) {
|
|
325
|
+
if (now - end > NEEDS_FOR) return null;
|
|
326
|
+
const n = waiting ? { kind: "step", what: clipWords(String(waiting.step || "").replace(/^\s*👤\s*(You\s*(\([^)]*\))?:)?\s*/, "").trim(), 240), check: "", label: "", waiting: true } : leftToYou(final);
|
|
327
|
+
if (!n || !n.what) return null;
|
|
328
|
+
const key = digest(n.what);
|
|
329
|
+
if (loadSettled()[path] === key || takenUp(path, end)) return null;
|
|
330
|
+
return { ...n, key };
|
|
331
|
+
}
|
|
332
|
+
|
|
282
333
|
// ---- a step that's yours: wait for it -----------------------------------------
|
|
283
334
|
// An answer that picks a "👤 You:" option leaves a step for the user to do (put a
|
|
284
335
|
// key in .env, allow a command in Settings). Starting the agent before it's done
|
|
@@ -484,6 +535,9 @@ const AGENT_LIST = [
|
|
|
484
535
|
["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
|
|
485
536
|
["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
|
|
486
537
|
];
|
|
538
|
+
// The agent to offer a new user in one click (Home's "Pick your agent"): the first
|
|
539
|
+
// coding agent on this computer, in AGENT_LIST's order (Claude Code first). null if none.
|
|
540
|
+
function pickAgent() { const a = AGENT_LIST.find(([cmd]) => hasCmd(cmd)); return a ? { label: a[1], name: a[1].split(" — ")[0], tmpl: a[2] } : null; }
|
|
487
541
|
function detectHandoffs() {
|
|
488
542
|
const editors = [];
|
|
489
543
|
for (const [cmd, label, app] of IDE_LIST) {
|
|
@@ -885,8 +939,10 @@ function agentsList() {
|
|
|
885
939
|
const { tail, work, progress } = workOf(e.path, readRunLog(e.log));
|
|
886
940
|
const first = !seen.has(e.path); seen.add(e.path);
|
|
887
941
|
const facts = first && e.status !== "running" ? factsOf(e.path) : [];
|
|
888
|
-
|
|
942
|
+
const waiting = first && e.status !== "running" ? waitingFor(e.path) : null;
|
|
943
|
+
const needs = first && e.status !== "running" ? needsOf(e.path, { final: work ? tail : "", waiting, end: e.endedAt || Date.now() }) : null;
|
|
944
|
+
return { id: e.id, name: e.name, path: e.path, status: e.status, startedAt: e.startedAt, endedAt: e.endedAt, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting, needs, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
|
|
889
945
|
}).concat(earlierRuns());
|
|
890
946
|
}
|
|
891
947
|
|
|
892
|
-
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, 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?"),
|
|
58
|
-
// that ended
|
|
59
|
-
//
|
|
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
|
|
149
|
-
// (0 goes ahead, 1 skips); text: your own words, which go ahead with
|
|
150
|
-
// they say no. The handover's run (lanes.mjs allowHandover) reports back
|
|
151
|
-
// agent that asked; a
|
|
152
|
-
// ANSWERS.md
|
|
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)
|
|
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
|
|
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.
|
|
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": {
|