symbiot 0.52.0 → 0.52.1
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 +4 -0
- package/README.md +12 -0
- package/agents.mjs +75 -3
- package/handover.mjs +1 -1
- package/package.json +2 -2
- package/server.mjs +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
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.52.1
|
|
6
|
+
|
|
7
|
+
- **Agents stop asking you for permission inside your work.** An agent reaches your knowledge folders from the start, and an ops agent your repos too. When it needs more and proposes a list that stays inside the folders you gave Symbiot, asks for nothing that could run anything, and doesn't publish or reach another machine, Symbiot turns it on by itself and the agent carries on: no question reaches you. Only a list that reaches outside your work (`~/.ssh`, `~/.config`, another machine) still asks, and it says why. Asks already waiting clear the same way.
|
|
8
|
+
|
|
5
9
|
## 0.52.0
|
|
6
10
|
|
|
7
11
|
- **Your agents, on the Tasks screen.** No need to open Agents: a line at the top says where everything stands ("1 question for you · 1 ready for your OK · 2 at work"), and a blob per project shows what its agent did or is doing, what it asks you (its answers as buttons, or your own words), Review and approve when its work is ready, and the extra tasks it suggests, each with **+ task** or **Skip**. The liquid moves left to make room.
|
package/README.md
CHANGED
|
@@ -439,6 +439,18 @@ editor presets only open a tab and exit, so Symbiot can't see the agent you run
|
|
|
439
439
|
in them. Send to repos stays disabled until its
|
|
440
440
|
handoffs have started, so a double click can't send twice.
|
|
441
441
|
|
|
442
|
+
### What agents may do without asking
|
|
443
|
+
|
|
444
|
+
Your work is the folders you gave Symbiot (Settings → Folders, and Knowledge folders),
|
|
445
|
+
except the hidden folders at the top of your home (`~/.ssh`, `~/.config`). An agent
|
|
446
|
+
reaches your knowledge folders from the start, and an ops agent your repos too. When
|
|
447
|
+
one needs more, it proposes an allow list; if the list stays inside your work, can't
|
|
448
|
+
run anything at all (a shell, sudo, a wildcard `rm`) and doesn't publish or reach
|
|
449
|
+
another machine (`git push`, `npm publish`, `aws`, `docker`, `ssh`), Symbiot turns it
|
|
450
|
+
on for that agent's folder and the agent carries on, with no question for you. Every
|
|
451
|
+
list also blocks editing Symbiot's own settings. Only what reaches outside your work
|
|
452
|
+
comes to you, with the reason.
|
|
453
|
+
|
|
442
454
|
### Questions from your agent
|
|
443
455
|
|
|
444
456
|
An agent working in your terminal or IDE stops to ask you things: which way to go,
|
package/agents.mjs
CHANGED
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// tab, and the questions an unattended agent leaves for the user.
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import { join, dirname } from "node:path";
|
|
6
|
+
import { join, dirname, resolve, relative } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
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 } from "./work.mjs";
|
|
12
12
|
import { parseFacts } from "./handover.mjs";
|
|
13
|
+
import { scanRoots, repoPathMap } from "./scan.mjs";
|
|
14
|
+
import { knowledgeFolders } from "./knowledge.mjs";
|
|
13
15
|
|
|
14
16
|
const HANDOFFS = []; // live registry of agents Symbiot has handed work to
|
|
15
17
|
// ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
|
|
@@ -159,7 +161,7 @@ function runHandoff(repoPath, { force = false } = {}) {
|
|
|
159
161
|
let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
|
|
160
162
|
const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
|
|
161
163
|
releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
|
|
162
|
-
tmpl = withStream(withConnectors(tmpl, repoPath));
|
|
164
|
+
tmpl = withStream(withScope(withConnectors(tmpl, repoPath), repoPath));
|
|
163
165
|
if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
|
|
164
166
|
const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
|
|
165
167
|
try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} // the step's done (or this is Start it anyway): this is the run it waited for
|
|
@@ -168,6 +170,7 @@ function runHandoff(repoPath, { force = false } = {}) {
|
|
|
168
170
|
const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, (code) => {
|
|
169
171
|
try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
|
|
170
172
|
noteBlocked(repoPath, tmpl, e.startedAt, code);
|
|
173
|
+
setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
|
|
171
174
|
startHeldTasks(repoPath); // tasks sent while it ran land now; start on them as that Send would have
|
|
172
175
|
}, env);
|
|
173
176
|
if (!e) return null;
|
|
@@ -673,6 +676,75 @@ function installAllowlist(path) {
|
|
|
673
676
|
try { mkdirSync(join(path, ".claude"), { recursive: true }); writeFileSync(f, JSON.stringify(cur, null, 2) + "\n", { mode: 0o600 }); } catch { return null; }
|
|
674
677
|
return [`the allow list for this folder (${allow.length} rule${allow.length === 1 ? "" : "s"})`];
|
|
675
678
|
}
|
|
679
|
+
// ---- the owner's work, and allow lists inside it ---------------------------------------
|
|
680
|
+
// What the owner told Symbiot is their work: the folders it scans (Settings → Folders) and
|
|
681
|
+
// their knowledge folders, but not a hidden folder at the top of their home (~/.ssh,
|
|
682
|
+
// ~/.config: keys and settings), bar the folders Symbiot's own agents run in. An agent
|
|
683
|
+
// reaches its knowledge folders from the start (withScope), and an allow list it proposes
|
|
684
|
+
// that stays inside this work, asks for nothing wide, and doesn't publish or reach another
|
|
685
|
+
// machine is turned on by itself when its run ends (autoAllow): no question for the user.
|
|
686
|
+
const PUBLISH_RULE = /^Bash\(\s*(git push|git\s+-C\s+\S+\s+push|npm publish|yarn publish|pnpm publish|aws|gcloud|az|kubectl|docker|podman|terraform|ssh|scp|sftp|rsync|ftp|heroku|vercel|netlify|fly|flyctl)\b/i;
|
|
687
|
+
function workRoots() {
|
|
688
|
+
const roots = new Set();
|
|
689
|
+
try { for (const r of scanRoots()) roots.add(r); } catch {}
|
|
690
|
+
try { for (const k of knowledgeFolders()) roots.add(k.path); } catch {}
|
|
691
|
+
return [...roots].filter(Boolean).map((r) => r.replace(/\/+$/, "") || "/");
|
|
692
|
+
}
|
|
693
|
+
function inWork(p, roots = workRoots()) {
|
|
694
|
+
if (!p) return false;
|
|
695
|
+
p = resolve(String(p).replace(/^~(?=\/|$)/, homedir()).replace(/[/*]+$/, "")) || "/";
|
|
696
|
+
const drafts = join(CONFIG_DIR, "drafts");
|
|
697
|
+
if (p === drafts || p.startsWith(drafts + "/")) return true;
|
|
698
|
+
if (!roots.some((r) => p === r || p.startsWith(r === "/" ? "/" : r + "/"))) return false;
|
|
699
|
+
const rel = relative(homedir(), p);
|
|
700
|
+
if (!rel.startsWith("..") && rel.split("/")[0].startsWith(".")) return false; // ~/.ssh, ~/.config…
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
// The paths a rule names: Read(/x/**), Bash(find /x:*), additionalDirectories' /x.
|
|
704
|
+
const rulePaths = (r) => (String(r).match(/(?:~|\/)[^\s:*()"',]*/g) || []).filter((x) => x === "~" || x.length > 1);
|
|
705
|
+
// Whether a proposed list can be turned on without asking: { ok } or { why }.
|
|
706
|
+
function allowlistInWork(path) {
|
|
707
|
+
let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return { why: "no list" }; }
|
|
708
|
+
const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
|
|
709
|
+
const allow = list(p.allow), dirs = list(p.additionalDirectories), roots = [...workRoots(), path];
|
|
710
|
+
const wide = allow.filter(wideRule); if (wide.length) return { why: "wide: " + wide.join(", ") };
|
|
711
|
+
const pub = allow.filter((r) => PUBLISH_RULE.test(r)); if (pub.length) return { why: "publishes or reaches another machine: " + pub.join(", ") };
|
|
712
|
+
const out = [...dirs, ...allow.flatMap(rulePaths)].filter((x) => !inWork(x, roots));
|
|
713
|
+
if (out.length) return { why: "outside your work: " + [...new Set(out)].slice(0, 3).join(", ") };
|
|
714
|
+
return { ok: true, allow, dirs };
|
|
715
|
+
}
|
|
716
|
+
// A run ended asking for its proposed list: inside the work, it's turned on, the
|
|
717
|
+
// question is answered for the user, and the agent starts again. Once per proposal
|
|
718
|
+
// (.symbiot/autoallow.json), so an agent can't loop on it; past that it's the user's.
|
|
719
|
+
function autoAllow(path) {
|
|
720
|
+
if (!path || runningHandoff(path)) return null;
|
|
721
|
+
const f = join(path, ".symbiot", "allowlist.proposed.json"); let raw = ""; try { raw = readFileSync(f, "utf8"); } catch { return null; }
|
|
722
|
+
const q = agentQuestions(path, "").questions.find((x) => (x.options || []).some((o) => /allowlist\.proposed\.json/i.test(o)) || /allowlist\.proposed\.json/i.test(x.context || ""));
|
|
723
|
+
if (!q) return null;
|
|
724
|
+
const mark = join(path, ".symbiot", "autoallow.json"), d = digest(raw); let done = []; try { done = JSON.parse(readFileSync(mark, "utf8")); } catch {}
|
|
725
|
+
if (done.includes(d)) return null;
|
|
726
|
+
const v = allowlistInWork(path); if (!v.ok) return { asked: true, why: v.why };
|
|
727
|
+
const g = installAllowlist(path); if (!g || g.refused) return null;
|
|
728
|
+
try { writeFileSync(mark, JSON.stringify([...done, d].slice(-20))); } catch {}
|
|
729
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
730
|
+
try { writeFileSync(join(path, ".symbiot", "ANSWERS.md"), (readSymbiot(path, "ANSWERS.md") || "# Answers from the user\nAnswers to the questions in QUESTIONS.md, newest last. Follow them; ask again in QUESTIONS.md if one is unclear.\n").replace(/\s*$/, "\n") + `\n### ${q.q}\nSymbiot turned your list on (it stays inside the user's work and asks for nothing wide): .claude/settings.local.json in this folder. Carry on.\n_answered ${day}_\n`); } catch {}
|
|
731
|
+
try { unlinkSync(join(path, ".symbiot", WAITING)); } catch {}
|
|
732
|
+
const e = runHandoff(path, { force: true });
|
|
733
|
+
return { allowed: true, rerun: e && e.id ? e.id : null };
|
|
734
|
+
}
|
|
735
|
+
// Every run that stopped asking for its list, now: on start and every so often, so
|
|
736
|
+
// asks from before this version clear too.
|
|
737
|
+
function autoAllowSweep() { const out = []; for (const r of loadRuns()) { try { const a = autoAllow(r.path); if (a && a.allowed) out.push(r.path); } catch {} } return out; }
|
|
738
|
+
// Every agent reaches the owner's knowledge folders from the start; an ops run (no repo
|
|
739
|
+
// of its own) their repos too, the way a person at their desk would.
|
|
740
|
+
function withScope(tmpl, repoPath) {
|
|
741
|
+
if (!isClaudeCmd(tmpl)) return tmpl;
|
|
742
|
+
const dirs = new Set(); try { for (const k of knowledgeFolders()) dirs.add(k.path); } catch {}
|
|
743
|
+
if (/\/drafts\/act-[^/]+$/.test(String(repoPath || ""))) { try { for (const p of Object.values(repoPathMap() || {})) dirs.add(p); } catch {} }
|
|
744
|
+
dirs.delete(repoPath);
|
|
745
|
+
let cmd = tmpl; for (const d of dirs) if (d && !cmd.includes(`--add-dir "${d}"`)) cmd += ` --add-dir "${d}"`;
|
|
746
|
+
return cmd;
|
|
747
|
+
}
|
|
676
748
|
function grantFromStep(step, path = "", notes = {}) {
|
|
677
749
|
const t = String(step || "");
|
|
678
750
|
if (path && /allowlist\.proposed\.json|settings\.local\.json|press allow|\ballow list\b/i.test(t)) { const g = installAllowlist(path); if (g && g.refused) { notes.refused = g.refused; return null; } if (g) return g; }
|
|
@@ -771,4 +843,4 @@ function agentsList() {
|
|
|
771
843
|
}).concat(earlierRuns());
|
|
772
844
|
}
|
|
773
845
|
|
|
774
|
-
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
|
|
846
|
+
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , autoAllow, autoAllowSweep, allowlistInWork, inWork, withScope };
|
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
|
|
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.";
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.52.
|
|
3
|
+
"version": "0.52.1",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
75
75
|
},
|
|
76
76
|
"scripts": {
|
|
77
|
-
"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/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",
|
|
77
|
+
"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/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",
|
|
78
78
|
"lint": "eslint ."
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
package/server.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { createServer } from "node:http";
|
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
14
14
|
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
|
|
15
|
-
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths } from "./agents.mjs";
|
|
15
|
+
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep } from "./agents.mjs";
|
|
16
16
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
17
17
|
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
18
18
|
import { computeDrift } from "./drift.mjs";
|
|
@@ -424,6 +424,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
424
424
|
startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
|
|
425
425
|
setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
|
|
426
426
|
setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
|
|
427
|
+
// an agent's allow list that stays inside your work is turned on by itself, and the agent carries on
|
|
428
|
+
setTimeout(() => { try { autoAllowSweep(); } catch {} }, 4000).unref();
|
|
429
|
+
setInterval(() => { try { autoAllowSweep(); } catch {} }, 20000).unref();
|
|
427
430
|
// knowledge folders: changed files re-read (a stat per file when nothing changed), then checked again for where two files disagree (checks.mjs)
|
|
428
431
|
const knowTick = () => { try { const r = knowledgeTick(); if (r && (r.read || r.removed || !checksState().at)) runChecks(); } catch {} };
|
|
429
432
|
setTimeout(knowTick, 5000).unref(); setInterval(knowTick, 3 * 60 * 1000).unref();
|