symbiot 0.50.0 → 0.50.2

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,17 @@
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.50.2
6
+
7
+ - **An allow list is one click.** When an agent needs permissions it doesn't have, it proposes them, as narrow as the task needs, in `.symbiot/allowlist.proposed.json` and offers it as a choice. Pick it in the app and Symbiot turns it on for that folder only and runs the agent again: no copying files, no running claude in a terminal. A list that would let an agent run anything at all (a shell, sudo, rm) is never turned on by a click.
8
+ - **Fixed:** a flag could end up in the agent command as a rule (`Bash(--allowedtools:*)`). It can't any more, and an old one is cleaned out on the next grant.
9
+
10
+ ## 0.50.1
11
+
12
+ - **A permission you pick is given.** When an agent asks for one ("let agents read ~/.config/symbiot/screens") and you choose it in the app, Symbiot grants it then and there (as Settings' grant boxes do) and the agent carries on, instead of leaving it as a step for you to do by hand. Steps only you can do, like pasting your own message, are still yours.
13
+ - **The chat on home rolls.** Newest at the bottom by the input; older lines fade as they rise and dissolve before they reach the droplets. Point at it or scroll back to read the whole conversation clearly.
14
+ - **No asterisks in names.** An agent's name on home no longer shows its markdown (`**What's needed:**`).
15
+
5
16
  ## 0.50.0
6
17
 
7
18
  - **The Map, by nearest neighbours.** Your repos are droplets in the liquid, each beside the repos most like it: what they're built with, the weeks you work on them, and what they're about (from their READMEs). The closest merge into one shape, the next are joined by a thread of liquid; each cluster is named over its region; what you haven't touched in a while is smaller and sinks; "you are here" sits where your recent work is. Point at a repo to see its neighbours and why; click for its details, now with **Most like it** and your last 12 weeks. Zoom and fit buttons, and the languages and tools you build with underneath instead of a coloured legend. Screens folds away.
package/agents.mjs CHANGED
@@ -46,6 +46,7 @@ function setHandoffCmd(cmd) {
46
46
  // looked bare and became Bash(Bashnpm install:*:*).
47
47
  function grantRule(tool) {
48
48
  let t = String(tool || "").trim().replace(/^["'`]+|["'`]+$/g, "").trim();
49
+ if (/^-/.test(t)) return ""; // a flag (--allowedTools) is never a tool
49
50
  if (/^[A-Za-z]+\(.*\)$/.test(t)) return t;
50
51
  t = t.replace(/[()"'`]/g, "").replace(/:\*$/, "").trim();
51
52
  return t ? `Bash(${t}:*)` : "";
@@ -62,7 +63,7 @@ function grantAgent({ tool, dir } = {}) {
62
63
  if (!isClaudeCmd(cmd)) return { error: "Grants apply to the Claude agent command. Pick a Claude preset first, or edit the command directly." };
63
64
  if (tool) cmd = allowTool(cmd, grantRule(tool));
64
65
  if (dir) { const d = String(dir).trim(); if (d && !cmd.includes(`--add-dir "${d}"`)) cmd += ` --add-dir "${d}"`; }
65
- cmd = cmd.replace(/\s+/g, " ").trim();
66
+ cmd = cmd.replace(/\s*"Bash\(-[^"]*\)"/g, "").replace(/\s+/g, " ").trim(); // and drop any flag that got in as a rule before
66
67
  cfg.agentCmd = cmd; saveConfig(cfg);
67
68
  return { ok: true, cmd };
68
69
  }
@@ -573,6 +574,38 @@ function agentQuestions(path, repo) {
573
574
  // An answer that picks a "👤 You:" option is a step for the user: they're told
574
575
  // it's still theirs (`yours`), and the next run waits for it (waiting.json).
575
576
  // A folder a run started in before a restart can be answered too (runs.json).
577
+ // A permission picked in the app is given then and there: the click is the user's
578
+ // say-so. "let agents read ~/x" / "allow /x" becomes --add-dir, "let agents run
579
+ // \`tool\`" a grant for that tool (never sudo/rm), as Settings' grant boxes do. What's
580
+ // granted isn't a step left for the user. Returns what was granted, or null.
581
+ // An allow list an agent proposed (.symbiot/allowlist.proposed.json) is turned on for
582
+ // that folder only (.claude/settings.local.json, merged), unless it would let an agent
583
+ // run anything at all (a shell, sudo, rm, Bash(*)): then it stays the user's step.
584
+ const WIDE_RULE = /^Bash(\((\*|sudo|su|rm|bash|sh|zsh|dash|dd|mkfs|chmod|chown|eval|exec)\b[^)]*\))?$|^Bash\(\*/i;
585
+ function installAllowlist(path) {
586
+ let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return null; }
587
+ const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
588
+ const allow = list(p.allow), deny = list(p.deny), dirs = list(p.additionalDirectories);
589
+ if (!allow.length && !dirs.length) return null;
590
+ if (allow.some((r) => WIDE_RULE.test(r))) return null;
591
+ const f = join(path, ".claude", "settings.local.json");
592
+ let cur = {}; try { cur = JSON.parse(readFileSync(f, "utf8")); } catch {}
593
+ const perm = cur.permissions || {}, merge = (a, b) => [...new Set([...list(a), ...b])];
594
+ cur.permissions = { ...perm, allow: merge(perm.allow, allow), deny: merge(perm.deny, deny), additionalDirectories: merge(perm.additionalDirectories, dirs) };
595
+ try { mkdirSync(join(path, ".claude"), { recursive: true }); writeFileSync(f, JSON.stringify(cur, null, 2) + "\n", { mode: 0o600 }); } catch { return null; }
596
+ return [`the allow list for this folder (${allow.length} rule${allow.length === 1 ? "" : "s"})`];
597
+ }
598
+ function grantFromStep(step, path = "") {
599
+ const t = String(step || "");
600
+ if (path && /allowlist\.proposed\.json|settings\.local\.json|press allow|\ballow list\b/i.test(t)) { const g = installAllowlist(path); if (g) return g; }
601
+ if (!/only you: a permission|only you: what (this|the) agent|\b(let|allow) (the )?agents?\b|\ballow (access|reading)\b/i.test(t)) return null;
602
+ const got = [];
603
+ const dir = (t.match(/(~\/[^\s,;)`'"]+|\/(?:home|Users|opt|srv|mnt|media|tmp)\/[^\s,;)`'"]+)/) || [])[1];
604
+ if (dir) { const d = dir.replace(/^~(?=\/)/, homedir()).replace(/[.]$/, ""); const r = grantAgent({ dir: d }); if (r && r.ok) got.push(d); }
605
+ const tool = (t.match(/\b(?:run|use)\s+`([A-Za-z0-9._+-]+)`/) || [])[1];
606
+ if (tool && !/^(sudo|rm|dd|mkfs)$/.test(tool)) { const r = grantAgent({ tool }); if (r && r.ok) got.push(tool); }
607
+ return got.length ? got : null;
608
+ }
576
609
  const stepText = (a) => String(a).split("🤖")[0].replace(/^\s*👤\s*(You:)?\s*/, "").replace(/\s*\(recommended\)\s*$/i, "").trim();
577
610
  function answerQuestions(path, answers, opts = {}) {
578
611
  path = String(path || "");
@@ -594,7 +627,9 @@ function answerQuestions(path, answers, opts = {}) {
594
627
  const youOpts = (q) => ((asked.get(qKey(q)) || {}).options || []).filter(yourStep).map((o) => o.split("🤖")[0]);
595
628
  const steps = rows.filter((x) => yourStep(x.a) || holdAnswer(x.a)).map((x) => yourStep(x.a) ? { step: stepText(x.a), named: x.a.split("🤖")[0] }
596
629
  : { step: (youOpts(x.q).map(stepText)[0] || x.a).trim(), named: [x.a, ...youOpts(x.q)].join(" ") });
597
- const yours = steps.map((s) => s.step).filter(Boolean);
630
+ const granted = [];
631
+ const yours = steps.map((s) => s.step).filter(Boolean).filter((st) => { const g = grantFromStep(st, path); if (g) granted.push(...g); return !g; });
632
+ if (granted.length) out.granted = granted;
598
633
  if (yours.length) {
599
634
  const step = yours.join(" "), files = namedFiles(steps.map((s) => s.named).join(" "), path);
600
635
  try { writeFileSync(join(path, ".symbiot", WAITING), JSON.stringify({ step, files: files.map((f) => ({ ...f, sig: fileSig(f.path) })), cmd: runCmd(path), at: Date.now(), rerun: !!opts.rerun })); } catch {}
@@ -604,6 +639,7 @@ function answerQuestions(path, answers, opts = {}) {
604
639
  return out;
605
640
  }
606
641
  if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e && e.blocked) out.note = "Answers saved. " + e.note; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
642
+ if (out.granted) out.note = "Allowed " + out.granted.join(", ") + " for your agents" + (opts.rerun ? ", and carried on. " : ". ") + (out.note || "");
607
643
  return out;
608
644
  }
609
645
  // The open tasks' titles in a held brief (TASKS.next.md), or null if nothing is held.
package/handover.mjs CHANGED
@@ -21,7 +21,7 @@ function handoverRules(lanes = [], here = "") {
21
21
  }
22
22
  // The rule for options in QUESTIONS.md: the user gets only what no agent can do,
23
23
  // and the option says which kind it is.
24
- 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.";
24
+ 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.";
25
25
 
26
26
  // What an agent handed over: [{ lane, text }] from its .symbiot/HANDOFF.md.
27
27
  function parseHandoffs(md) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.50.0",
3
+ "version": "0.50.2",
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": {
package/ui.mjs CHANGED
@@ -65,7 +65,10 @@ body.lq-light #lqask::placeholder{color:#6A7480}
65
65
  #lqsend{flex:none;width:40px;height:40px;padding:0;display:grid;place-items:center;border-radius:50%;border:0;background:#F3F0EA;color:#0A0B0D;cursor:pointer}
66
66
  body.lq-light #lqsend{background:#151A21;color:#FFFFFF}
67
67
  body.lq-look-pearl #lqsend{background:linear-gradient(180deg,#FFFFFF 0%,#D9DDE2 45%,#9AA1AA 55%,#E8EAED 100%);color:#1B1E23;box-shadow:0 2px 6px rgba(0,0,0,.18),inset 0 0 0 1px rgba(0,0,0,.08)}
68
- #lqtalk{position:absolute;left:50%;bottom:88px;transform:translateX(-50%);width:min(640px,calc(100vw - 32px));display:flex;flex-direction:column;gap:16px;z-index:3;max-height:46vh;overflow-x:hidden;overflow-y:auto;scrollbar-width:none;padding-top:36px;-webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 40px);mask-image:linear-gradient(to bottom,transparent 0,#000 40px)}
68
+ #lqtalk{position:absolute;left:50%;bottom:88px;transform:translateX(-50%);width:min(640px,calc(100vw - 32px));display:flex;flex-direction:column;gap:16px;z-index:3;max-height:min(34vh,320px);overflow-x:hidden;overflow-y:auto;scrollbar-width:none;padding-top:90px;scroll-behavior:smooth;-webkit-mask-image:linear-gradient(to bottom,transparent 0,rgba(0,0,0,.12) 22%,rgba(0,0,0,.6) 48%,#000 72%);mask-image:linear-gradient(to bottom,transparent 0,rgba(0,0,0,.12) 22%,rgba(0,0,0,.6) 48%,#000 72%);transition:max-height .3s ease}
69
+ #lqtalk:hover,#lqtalk:focus-within,#lqtalk.back{max-height:min(56vh,560px);-webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 60px);mask-image:linear-gradient(to bottom,transparent 0,#000 60px)}
70
+ #lqtalk .lqmsg{transition:opacity .4s ease}
71
+ #lqtalk:hover .lqmsg,#lqtalk.back .lqmsg{opacity:1 !important}
69
72
  #lqtalk::-webkit-scrollbar{display:none}
70
73
  .lqmsg{align-self:flex-start;max-width:92%;padding:0;border:0;background:none;color:#ECE9E4;font-size:15px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere;text-shadow:0 1px 12px rgba(0,0,0,.7)}
71
74
  .lqmsg.me{align-self:flex-end;max-width:80%;padding:8px 14px;border-radius:18px;background:rgba(255,255,255,.09);color:#C9CDD3;font-size:14.5px;text-shadow:none}
@@ -1888,7 +1891,7 @@ a.tx=Math.max(a.r+60,Math.min(S.w-a.r-60,a.tx));a.ty=Math.max(140+a.r,Math.min(S
1888
1891
  var bad=false;for(var i2=0;i2<n&&!bad;i2++)for(var j2=i2+1;j2<n;j2++){var p=L[i2],q=L[j2],ddx=p.tx-q.tx,ddy=p.ty-q.ty;if(Math.sqrt(ddx*ddx+ddy*ddy)<lqSep(p,q,ddx,ddy)-1){bad=true;break;}}
1889
1892
  if(!bad)return true;L.forEach(function(x){x.r*=0.9;});coreR*=0.9;}return false;}
1890
1893
  function lqLabels(){var el=$('lqdrops');if(!el)return;
1891
- el.innerHTML=LQ.drops.map(function(d,i){return "<div class='lqd lq-"+d.kind+(LQ.openTag===d.id?' open':'')+"' data-i='"+i+"'><button type='button' class='lt' aria-expanded='"+(LQ.openTag===d.id)+"'><span>"+esc(d.title)+"</span>"+(d.kind==='proj'&&d.sub?"<span class='lm'>"+esc(d.sub)+"</span>":"")+"</button>"+(d.sub?"<small>"+esc(d.sub)+"</small>":"")+"<button type='button' class='lgo'>Open &rsaquo;</button></div>";}).join('');
1894
+ el.innerHTML=LQ.drops.map(function(d,i){return "<div class='lqd lq-"+d.kind+(LQ.openTag===d.id?' open':'')+"' data-i='"+i+"'><button type='button' class='lt' aria-expanded='"+(LQ.openTag===d.id)+"'><span>"+esc(String(d.title||'').split('**').join(''))+"</span>"+(d.kind==='proj'&&d.sub?"<span class='lm'>"+esc(d.sub)+"</span>":"")+"</button>"+(d.sub?"<small>"+esc(d.sub)+"</small>":"")+"<button type='button' class='lgo'>Open &rsaquo;</button></div>";}).join('');
1892
1895
  LQ.btns=[];LQ.lts=[];el.querySelectorAll('.lqd').forEach(function(b){LQ.btns.push(b);var d=LQ.drops[+b.getAttribute('data-i')],lt=b.querySelector('.lt'),go=b.querySelector('.lgo');LQ.lts.push(lt);
1893
1896
  if(lt)lt.addEventListener('click',function(ev){lqOpen(d,ev);});
1894
1897
  if(go)go.addEventListener('click',function(ev){LQ.openTag=null;lqOpen(d,ev);});});
@@ -1923,7 +1926,9 @@ function lqPool(shape){if(shape==='map'&&LM.items&&LM.items.length)setTimeout(fu
1923
1926
  function lqSink(){lqRemember();var b=document.body;if(b&&b.classList)b.classList.remove('lq-pooled');LQ.mode='aware';lqAct();lqLoad(false);}
1924
1927
  function lqAct(){LQ.lastAct=Date.now();if(LQ.mode==='rest'){LQ.mode='aware';lqLoad(true);}}
1925
1928
  function lqTalkMode(on){LQ.talking=on;var b=document.body;if(b&&b.classList)b.classList.toggle('lq-talking',on);}
1926
- function lqTalkShow(wait){var el=$('lqtalk');if(!el)return;el.innerHTML=LQ.talk.map(function(m){return "<div class='lqmsg"+(m.me?' me':'')+"'>"+esc(m.text)+stepsHtml(m.steps)+"</div>";}).join('')+(wait?"<div class='lqmsg thinking'>Thinking: recalling what it knows, reading what's here&hellip;</div>":'');if(el.scrollHeight)el.scrollTop=el.scrollHeight;}
1929
+ function lqTalkShow(wait){var el=$('lqtalk');if(!el)return;var n=LQ.talk.length;el.innerHTML=LQ.talk.map(function(m,i){var age=n-1-i,op=age<2?1:age===2?0.7:age===3?0.45:0.25;return "<div class='lqmsg"+(m.me?' me':'')+"' style='opacity:"+op+"'>"+esc(m.text)+stepsHtml(m.steps)+"</div>";}).join('')+(wait?"<div class='lqmsg thinking'>Thinking: recalling what it knows, reading what's here&hellip;</div>":'');if(el.scrollHeight)el.scrollTop=el.scrollHeight;
1930
+ // scrolled up to read back: the whole history shows, clear; back at the bottom it rolls on again
1931
+ if(!el.wired&&el.addEventListener){el.wired=true;el.addEventListener('scroll',function(){var up=el.scrollHeight-el.scrollTop-el.clientHeight>24;if(el.classList)el.classList.toggle('back',up);});}}
1927
1932
  function lqSay(){var i=$('lqask');var q=((i&&i.value)||'').trim();if(!q)return;i.value='';lqAct();
1928
1933
  var low=q.toLowerCase().replace(/^(please |can you |could you )/,''),hit='';
1929
1934
  Object.keys(LQNAMES).forEach(function(k){var nm=LQNAMES[k].toLowerCase();if(hit)return;['open ','show ','go to ','take me to '].forEach(function(v){if(low.indexOf(v+nm)===0||low.indexOf(v+'my '+nm)===0)hit=k;});if(low===nm)hit=k;});