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 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: write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`) and offer `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`. Picking it in Symbiot turns it on for this folder and runs you again; don't ask them to copy files or run claude in a terminal for 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.";
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.0",
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();