symbiot 0.63.0 → 0.63.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.63.2
6
+
7
+ - **Permission cards stop landing on you for Symbiot's own plumbing.** An agent connecting to a computer this one already connects to (your own server) no longer asks you, and a request too broad to allow safely goes back to the agent to spell out, not to you.
8
+ - **What still needs you reads in plain words.** A permission card says what it lets the agent do, like "use your phone over the USB cable" or "connect to the computer at 92.4.133.97", with an Allow button. No file names or commands, on the card or when you hover over it.
9
+
10
+ ## 0.63.1
11
+
12
+ - **Fewer things put on your plate.** A permission an agent needs for your own work (building your app, using your phone over USB, recording its screen) now turns on by itself instead of showing up as a card: a tool installed in `~/.local/bin` and the marketing lane's own folder no longer count as "outside your work".
13
+ - **Your agent's own sensible answer is taken for you.** When an agent asks something and its recommended answer is a step it takes itself and can undo, Symbiot picks it and the agent carries on. Approvals, publishing, sending, money, accounts and other machines still come to you.
14
+ - **Open tasks don't just sit there.** A lane whose run ends with tasks still open starts again by itself, as long as it keeps ticking them off, and a question about one task no longer stops the rest.
15
+
5
16
  ## 0.63.0
6
17
 
7
18
  - **The Android app is built again.** 0.62.1's release went out without its APK: a link to a build folder on this computer had been committed by mistake, so GitHub's build couldn't make the folder. It's gone, and the ignore rule now covers a link as well as a folder. 0.63.0 carries 0.62.1's phone fix (setup going to **Your computer**).
package/agents.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // tab, and the questions an unattended agent leaves for the user.
4
4
  import { MARKETING_DIR } from "./marketing.mjs";
5
5
  import { licenceState, isPro, can, canWorkIn } from "./licence.mjs";
6
- import { spawn } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import { join, dirname, resolve, relative } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -32,7 +32,7 @@ const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
32
32
  const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
33
33
  // Plain words only: in the Orca preset this passes through two shells, so no
34
34
  // backticks or $ (escDq covers one level).
35
- const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. If you need a decision, or have ideas or options for the user, write them to .symbiot/QUESTIONS.md as TASKS.md explains, and read .symbiot/ANSWERS.md first if it exists. Confirm before anything destructive.";
35
+ const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement its unchecked items in this repo, one at a time, urgent ones first: finish the first one completely (built, tested, ticked) before you look at the next, then carry on with the next while you can. An item that is already done in the code, or no longer applies, you tick with a one-line note saying why. Never end a run without finishing or ticking at least one item, and never stop because the list is long: another run picks up where you left off. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. If you need a decision, or have ideas or options for the user, write them to .symbiot/QUESTIONS.md as TASKS.md explains, then skip that item and carry on with the next: a question never ends the run while other items can be done. Read .symbiot/ANSWERS.md first if it exists. Confirm before anything destructive.";
36
36
  function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(migrateClaudeCmd(cfg.agentCmd)) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
37
37
  // Save the template ("" clears it). Either way the legacy `ide` key goes.
38
38
  function setHandoffCmd(cmd) {
@@ -235,7 +235,7 @@ function sandboxEnv(box, { token = () => (hasCmd("gh") ? sh("gh auth token").tri
235
235
  // For Settings → Handoff: { on, ready, missing } (on: repo runs get it).
236
236
  function sandboxState() { const n = sandboxNeeds(); return { on: loadConfig().agentSandbox !== false && trustFull(), ready: n.ready, missing: n.missing, ...(n.unsupported ? { unsupported: true } : {}) }; }
237
237
  const SESSION = "session.json", RESUME_MAX = 8, RESUME_AGE = 2 * 86400000;
238
- const RESUME_PROMPT = "The user has answered: read the newest entries in .symbiot/ANSWERS.md and carry on with .symbiot/TASKS.md from where you were. Same rules as before.";
238
+ const RESUME_PROMPT = "The user has answered: read the newest entries in .symbiot/ANSWERS.md and carry on with .symbiot/TASKS.md from where you were: finish the item you were on, then the next. Same rules as before.";
239
239
  function sessionOf(path) { try { return JSON.parse(readSymbiot(path, SESSION)) || null; } catch { return null; } }
240
240
  // Resume when an answer has come in since its last run, and the conversation is fresh enough
241
241
  function resumeFor(path) {
@@ -1104,22 +1104,33 @@ function workRoots() {
1104
1104
  function inWork(p, roots = workRoots()) {
1105
1105
  if (!p) return false;
1106
1106
  p = resolve(String(p).replace(/^~(?=\/|$)/, homedir()).replace(/[/*]+$/, "")) || "/";
1107
- const drafts = join(CONFIG_DIR, "drafts");
1108
- if (p === drafts || p.startsWith(drafts + "/")) return true;
1107
+ for (const lane of [join(CONFIG_DIR, "drafts"), join(CONFIG_DIR, "marketing")]) if (p === lane || p.startsWith(lane + "/")) return true;
1109
1108
  if (!roots.some((r) => p === r || p.startsWith(r === "/" ? "/" : r + "/"))) return false;
1110
1109
  const rel = relative(homedir(), p);
1111
1110
  if (!rel.startsWith("..") && rel.split("/")[0].startsWith(".")) return false; // ~/.ssh, ~/.config…
1112
1111
  return true;
1113
1112
  }
1114
1113
  // The paths a rule names: Read(/x/**), Bash(find /x:*), additionalDirectories' /x.
1115
- const rulePaths = (r) => (String(r).match(/(?:~|\/)[^\s:*()"',]*/g) || []).filter((x) => x === "~" || x.length > 1);
1114
+ // A Bash rule's own program, when it lives in a bin folder (~/.local/bin/adb), is a tool
1115
+ // it runs, not a place it works in: only the paths after it count.
1116
+ const BIN_PROGRAM = /^(Bash\(\s*)(?:~|\/)[^\s:()]*\/s?bin\/[^\s:()/]+/;
1117
+ const rulePaths = (r) => (String(r).replace(BIN_PROGRAM, "$1").match(/(?:~|\/)[^\s:*()"',]*/g) || []).filter((x) => x === "~" || x.length > 1);
1118
+ // An ssh, scp or rsync rule whose every remote (user@host, user@host:path) is a host this
1119
+ // computer already connects to (it's in ~/.ssh/known_hosts) reaches the owner's own
1120
+ // machine, not another one: it counts as their work. A wildcard host never does.
1121
+ function knownHost(host) { try { return !/[*?]/.test(host) && spawnSync("ssh-keygen", ["-F", host], { encoding: "utf8", timeout: 5000 }).status === 0; } catch { return false; } }
1122
+ function toKnownHost(r, known = knownHost) {
1123
+ const m = String(r).match(/^Bash\(\s*(ssh|scp|rsync)\b(.*)\)$/); if (!m) return false;
1124
+ const hosts = m[2].split(/\s+/).filter((t) => t.includes("@") && !t.startsWith("-")).map((t) => t.split("@").pop().split(":")[0]).filter(Boolean);
1125
+ return hosts.length > 0 && hosts.every((h) => known(h));
1126
+ }
1116
1127
  // Whether a proposed list can be turned on without asking: { ok } or { why }.
1117
1128
  function allowlistInWork(path) {
1118
1129
  let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return { why: "no list" }; }
1119
1130
  const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
1120
1131
  const allow = list(p.allow), dirs = list(p.additionalDirectories), roots = [...workRoots(), path];
1121
1132
  const wide = allow.filter(wideRule); if (wide.length) return { why: "wide: " + wide.join(", ") };
1122
- const pub = allow.filter((r) => PUBLISH_RULE.test(r)); if (pub.length) return { why: "publishes or reaches another machine: " + pub.join(", ") };
1133
+ const pub = allow.filter((r) => PUBLISH_RULE.test(r) && !toKnownHost(r)); if (pub.length) return { why: "publishes or reaches another machine: " + pub.join(", ") };
1123
1134
  const out = [...dirs, ...allow.flatMap(rulePaths)].filter((x) => !inWork(x, roots));
1124
1135
  if (out.length) return { why: "outside your work: " + [...new Set(out)].slice(0, 3).join(", ") };
1125
1136
  return { ok: true, allow, dirs };
@@ -1134,7 +1145,15 @@ function autoAllow(path) {
1134
1145
  if (!q) return null;
1135
1146
  const mark = join(path, ".symbiot", "autoallow.json"), d = digest(raw); let done = []; try { done = JSON.parse(readFileSync(mark, "utf8")); } catch {}
1136
1147
  if (done.includes(d)) return null;
1137
- const v = allowlistInWork(path); if (!v.ok) return { asked: true, why: v.why };
1148
+ const v = allowlistInWork(path);
1149
+ if (!v.ok && /^wide: /.test(v.why)) { // the agent's to fix, not the user's: it writes the commands out and asks again
1150
+ try { writeFileSync(mark, JSON.stringify([...done, d].slice(-20))); } catch {}
1151
+ 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 didn't turn your list on, and didn't pass it to the user: ${v.why.slice(6)} would let you run anything (a script or a shell can hold any command). Write each command out exactly in .symbiot/allowlist.proposed.json instead, with the files and hosts it touches named (for a script, the commands inside it), and ask again.\n_${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
1152
+ try { unlinkSync(join(path, ".symbiot", WAITING)); } catch {}
1153
+ const e = runHandoff(path, { force: true });
1154
+ return { narrower: true, rerun: e && e.id ? e.id : null };
1155
+ }
1156
+ if (!v.ok) return { asked: true, why: v.why };
1138
1157
  const g = installAllowlist(path); if (!g || g.refused) return null;
1139
1158
  try { writeFileSync(mark, JSON.stringify([...done, d].slice(-20))); } catch {}
1140
1159
  const day = new Date().toISOString().slice(0, 10);
@@ -1145,7 +1164,47 @@ function autoAllow(path) {
1145
1164
  }
1146
1165
  // Every run that stopped asking for its list, now: on start and every so often, so
1147
1166
  // asks from before this version clear too.
1148
- 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; }
1167
+ // A question whose answer the agent already knows isn't the user's: when its own
1168
+ // recommended option is a step it takes itself (not 👤), and nothing in the question or
1169
+ // that option is an approval, goes out, costs money, touches an account or a machine, or
1170
+ // can't be undone, Symbiot picks it and the agent carries on. Everything else still comes to the user.
1171
+ const OUTWARD = /\b(approv\w*|publish\w*|releas\w*|push\w*|merg\w*|send\w*|sent|post\w*|repl\w*|e-?mail\w*|pay\w*|spend\w*|buy\w*|money|bill\w*|cost\w*|delet\w*|remov\w*|uninstall\w*|wip\w*|drop\w*|password\w*|2fa|token\w*|keys?|sign(?:ed|s)?[- ]?in|log(?:ged)?[- ]?in|accounts?|subscription|plan|tunnel\w*|prox\w*|ssh|vm|servers?|restart\w*|stop\w*|kill\w*|broker\w*|trad\w*|bank\w*|npm|deploy\w*|install\w*|phone|whatsapp|linkedin|github\.com)\b|\byour (?:default|settings?|profile)\b/i;
1172
+ function autoPick(q) {
1173
+ const rec = (q.options || []).filter((o) => /\(recommended\b/i.test(o));
1174
+ if (rec.length !== 1 || yourStep(rec[0])) return "";
1175
+ return OUTWARD.test(`${q.q} ${q.context || ""} ${rec[0]}`) ? "" : rec[0];
1176
+ }
1177
+ function autoDecide(path) {
1178
+ if (!path || runningHandoff(path)) return null;
1179
+ const picks = agentQuestions(path, "").questions.map((q) => ({ q: q.q, a: autoPick(q) })).filter((x) => x.a);
1180
+ if (!picks.length) return null;
1181
+ const r = answerQuestions(path, picks.map((x) => ({ q: x.q, a: `${x.a}\n(Symbiot picked your own recommendation: it's yours to do and can be undone, so the user wasn't asked.)` })), { rerun: true });
1182
+ return r && r.ok ? { decided: picks.length } : null;
1183
+ }
1184
+ // A lane whose run has ended with unticked tasks still in TASKS.md starts again by
1185
+ // itself, so open work never just sits there. Only while it's getting somewhere: after
1186
+ // the first continue, a lane starts again only if more tasks got ticked since the last
1187
+ // one, so a lane stuck on its questions doesn't loop. Parked, busy or blocked lanes are left alone.
1188
+ const CONTINUED = new Map(); // path -> ticked count when it was last continued
1189
+ const CONTINUE_AFTER = 2 * 60000;
1190
+ function continueLanes({ now = Date.now(), run = runHandoff, runs = loadRuns } = {}) {
1191
+ const out = [];
1192
+ for (const path of [...new Set(runs().map((r) => r.path).filter(Boolean))]) {
1193
+ try {
1194
+ if (runningHandoff(path) || isParked(path)) continue;
1195
+ const md = readSymbiot(path, "TASKS.md"), open = (md.match(/^\s*(?:[-*+]|\d+[.)])\s*\[ \]/gm) || []).length;
1196
+ if (!open) { CONTINUED.delete(path); continue; }
1197
+ let ended = 0; try { ended = statSync(join(path, ".symbiot", "agent.log")).mtimeMs; } catch { continue; }
1198
+ if (now - ended < CONTINUE_AFTER) continue;
1199
+ const ticked = (md.match(/^\s*(?:[-*+]|\d+[.)])\s*\[[xX]\]/gm) || []).length;
1200
+ if (CONTINUED.has(path) && ticked <= CONTINUED.get(path)) continue;
1201
+ if (blockedAgain(path, handoffCmd())) continue;
1202
+ const e = run(path); if (e && e.id && !e.busy && !e.blocked) { CONTINUED.set(path, ticked); out.push(path); }
1203
+ } catch {}
1204
+ }
1205
+ return out;
1206
+ }
1207
+ function autoAllowSweep() { const out = []; for (const r of loadRuns()) { try { const a = autoAllow(r.path); if (a && a.allowed) out.push(r.path); else if (autoDecide(r.path)) out.push(r.path); } catch {} } return out; }
1149
1208
  // Every agent reaches the owner's knowledge folders from the start; an ops run (no repo
1150
1209
  // of its own) their repos too, the way a person at their desk would.
1151
1210
  function withScope(tmpl, repoPath) {
@@ -1263,4 +1322,4 @@ function agentsList() {
1263
1322
  }).concat(earlierRuns());
1264
1323
  }
1265
1324
 
1266
- 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, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
1325
+ export { toKnownHost, continueLanes, autoPick, autoDecide, 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, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
package/handover.mjs CHANGED
@@ -27,7 +27,7 @@ function handoverRules(lanes = [], here = "") {
27
27
  }
28
28
  // The rule for options in QUESTIONS.md: the user gets only what no agent can do,
29
29
  // and the option says which kind it is.
30
- 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. Local git work (staging, committing, pulling, pushing, in this repo or another lane's) is never a 👤 step either: do it, or hand it to that repo's lane, and ask only for an OK in QUESTIONS.md before a push, with the files it pushes. Prefer what's already linked: Symbiot's own browser (`~/.config/symbiot/browser`) is signed in to the sites the user linked under Connections, so use that session over any route that needs a new sign-in, an app, a key or their consent (a platform's API, a developer app, OAuth). Check what's linked before you write a 👤 sign-in or link step, ask them to sign in only when a real attempt found the session expired (and say so), and never offer doing an agent's job by hand. When they do have to sign in to a site, name the site's address and the place in the app: `👤 You (only you: your sign-in): sign in to <site, e.g. www.domains.co.za/client/dashboard> in the Symbiot Browser (Settings → Connections → Add a site)`, never just \"in Symbiot's browser\": Symbiot puts a Sign in button on that card, which opens the window there. 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.";
30
+ 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. Local git work (staging, committing, pulling, pushing, in this repo or another lane's) is never a 👤 step either: do it, or hand it to that repo's lane, and ask only for an OK in QUESTIONS.md before a push, with the files it pushes. Prefer what's already linked: Symbiot's own browser (`~/.config/symbiot/browser`) is signed in to the sites the user linked under Connections, so use that session over any route that needs a new sign-in, an app, a key or their consent (a platform's API, a developer app, OAuth). Check what's linked before you write a 👤 sign-in or link step, ask them to sign in only when a real attempt found the session expired (and say so), and never offer doing an agent's job by hand. When they do have to sign in to a site, name the site's address and the place in the app: `👤 You (only you: your sign-in): sign in to <site, e.g. www.domains.co.za/client/dashboard> in the Symbiot Browser (Settings → Connections → Add a site)`, never just \"in Symbiot's browser\": Symbiot puts a Sign in button on that card, which opens the window there. 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` exactly (Symbiot shows the user that option as an Allow button, with your rules in plain words, so write the question itself in plain words and never repeat the file's name in it), 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.";
31
31
 
32
32
  // What a run hands back to Symbiot itself (handback.mjs reads it): facts for
33
33
  // memory, and emails that wait on a reply, which Symbiot then watches for; and
package/home.mjs CHANGED
@@ -355,7 +355,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
355
355
  if (!q) continue;
356
356
  const lane = laneOfPath(a.path, map), repo = lane || (lane === "" ? "" : a.name);
357
357
  you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
358
- title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), q: String(q.q || ""), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), ...signInCard([q.q, ...(q.options || []).slice(0, 2)]), shape: "agents" });
358
+ title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), ...plainAsk(a.path, q), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), ...signInCard([q.q, ...(q.options || []).slice(0, 2)]), shape: "agents" });
359
359
  }
360
360
  // What stopped and can't go on without you, as a question too: a handover that
361
361
  // couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
@@ -404,6 +404,42 @@ function noteAnswer(path, heading, text) {
404
404
  const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
405
405
  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 {}
406
406
  }
407
+ // An ask in words for someone who doesn't code: a permission request lists what the agent
408
+ // wants to run, with Allow / Don't allow; any file path left in the question or choices
409
+ // becomes "a file". The answer sent back is still the choice's own text (labels only change what shows).
410
+ const PATHISH = /`?(?:~|\.{0,2})?\/?(?:[\w.-]+\/)+[\w.-]+\.(?:json|md|mjs|js|env|txt|sh)`?|`?\b[\w-]+\.(?:json|mjs)\b`?/g;
411
+ // What a rule lets an agent do, said the way you'd say it to someone who doesn't code.
412
+ const base = (p) => String(p || "").replace(/[/*]+$/, "").split("/").filter(Boolean).pop() || "your files";
413
+ function PLAIN_RULE(r) {
414
+ const m = String(r).match(/^(\w+)\((.*)\)$/); if (!m) return "work in " + base(r);
415
+ const [, tool, arg] = m, a = arg.replace(/:?\*+$/, "").trim(), first = a.split(/\s+/)[0].replace(/:\*?$/, "").split("/").pop();
416
+ if (tool === "Read") return `read files in ${base(a)}`;
417
+ if (/^(Edit|Write|MultiEdit)$/.test(tool)) return `change files in ${base(a)}`;
418
+ if (tool !== "Bash") return `use ${tool}`;
419
+ const host = (a.match(/@([\w.-]+)/) || [])[1];
420
+ if (/^(ssh|scp|rsync)$/.test(first)) return host ? `connect to the computer at ${host}` : "connect to another computer";
421
+ if (first === "adb") return "use your phone over the USB cable";
422
+ if (/^(flutter|gradle|gradlew|godot|xcodebuild)$/.test(first) || /\bbuild\b/.test(a)) return "build the app";
423
+ if (first === "ffmpeg" || first === "ffprobe") return "make and trim video clips";
424
+ if (first === "git" && /\bpush\b/.test(a)) return "send code to GitHub";
425
+ if (/^(npm|yarn|pnpm)$/.test(first) && /\bpublish\b/.test(a)) return "publish a new version";
426
+ if (/^(bash|sh|zsh)$/.test(first)) return "run a script it wrote";
427
+ if (/^(python\d*|node)$/.test(first)) return "run code in this project";
428
+ if (/^(rm|shred)$/.test(first)) return "delete files";
429
+ if (first === "sudo") return "make changes as administrator";
430
+ return `run ${first}`;
431
+ }
432
+ function plainAsk(path, q) {
433
+ const text = String(q.q || ""), opts = (q.options || []).slice(0, 2);
434
+ let ask = text, labels = opts.map((o) => o.replace(PATHISH, "a file"));
435
+ if (opts.some((o) => /allowlist\.proposed/i.test(o))) {
436
+ let rules = []; try { const p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; rules = [...new Set([...(p.allow || []).map(PLAIN_RULE), ...(p.additionalDirectories || []).map((d) => `work in ${base(d)}`)])]; } catch {}
437
+ ask = `${text.replace(PATHISH, "a file")}${rules.length ? ` It wants to ${rules.slice(0, 6).join(", ")}${rules.length > 6 ? `, and ${rules.length - 6} more things` : ""}.` : ""} If you say no, it skips that part and tells you what it left out.`;
438
+ labels = opts.map((o) => (/allowlist\.proposed/i.test(o) ? "Allow" + (/recommended/i.test(o) ? " (recommended)" : "") : o.replace(PATHISH, "a file")));
439
+ }
440
+ return { q: ask.replace(PATHISH, "a file"), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
441
+ }
442
+
407
443
  function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
408
444
  id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
409
445
  const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
@@ -805,4 +841,4 @@ function setOnboarding({ step, skip, unskip, skipRest, done, restart } = {}) {
805
841
  return onboarding();
806
842
  }
807
843
 
808
- export { reportIdeasAdd, reportAsk, reportDraftAnswer, workTick, waitWhy, AUTO_GRACE, STUCK_AFTER, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, startOnboarding, ONB_STEPS, ONB_STEPS_PHONE, onbSteps, phoneBits , phoneSetupFirst };
844
+ export { plainAsk, reportIdeasAdd, reportAsk, reportDraftAnswer, workTick, waitWhy, AUTO_GRACE, STUCK_AFTER, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, startOnboarding, ONB_STEPS, ONB_STEPS_PHONE, onbSteps, phoneBits , phoneSetupFirst };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.63.0",
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.",
3
+ "version": "0.63.2",
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": {
7
7
  "symbiot": "index.mjs"
package/server.mjs CHANGED
@@ -18,7 +18,7 @@ import { createServer } from "node:http";
18
18
  import { randomBytes } from "node:crypto";
19
19
  import { EMBEDDED_UI } from "./ui.mjs";
20
20
  import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, moveSecrets, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
21
- import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull, readLastWords, sandboxState } from "./agents.mjs";
21
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, continueLanes, trustFull, readLastWords, sandboxState } from "./agents.mjs";
22
22
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
23
23
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap, setScanOptions, refreshRepos, REPO_STATE, reposState } from "./scan.mjs";
24
24
  import { computeDrift } from "./drift.mjs";
@@ -618,6 +618,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
618
618
  // an agent's allow list that stays inside your work is turned on by itself, and the agent carries on
619
619
  setTimeout(() => { try { autoAllowSweep(); } catch {} }, 4000).unref();
620
620
  setInterval(() => { try { autoAllowSweep(); } catch {} }, 20000).unref();
621
+ setInterval(() => { try { continueLanes(); } catch {} }, 60000).unref(); // a lane with open tasks and no run going starts again, while it gets somewhere
621
622
  // a finished run's last words, read by your AI for anything left to you that the patterns missed (agents.mjs readLastWords)
622
623
  setInterval(() => { readLastWords().catch(() => {}); }, 60000).unref();
623
624
  // knowledge folders: changed files re-read (a stat per file when nothing changed), then checked again for where two files disagree (checks.mjs)
package/ui.mjs CHANGED
@@ -3213,7 +3213,7 @@ if(y.kind!=='ask')return h+"<div class='bq'>"+esc(y.kind==='approve'?String(y.su
3213
3213
  var os=y.options||[];if(y.draft)h+=draftHtml(y.draft);h+="<div class='bq' title='"+escQ((y.q||'')+(y.why?' ('+y.why+')':''))+"'>"+esc(y.q||y.sub||'')+"</div>";
3214
3214
  if(y.fix==='handover'&&y.sub)h+="<div class='bfor' title='"+escQ(y.sub)+"'>for: "+esc(y.sub)+"</div>";
3215
3215
  if(y.signInTo)h+="<div class='bo'><button type='button' class='nopt bsign' title='opens "+escQ(y.signInTo.name)+" in the Symbiot Browser: sign in there, then click Done'><span class='bt'>Sign in to "+esc(y.signInTo.name)+" &rsaquo;</span></button></div>";
3216
- if(os.length)h+="<div class='bo'>"+os.map(function(o,j){return "<button type='button' class='nopt"+(j===0&&/[(]recommended[)]/i.test(o)?' rec':'')+"' data-j='"+j+"' title='"+escQ(o)+"'><span class='bt'>"+whoHtml(blobOpt(o))+"</span></button>";}).join('')+"</div><button type='button' class='bfree' aria-expanded='false'>"+(y.draft?'Change it':'or answer in your own words')+"</button>";
3216
+ if(os.length)h+="<div class='bo'>"+os.map(function(o,j){return "<button type='button' class='nopt"+(j===0&&/[(]recommended[)]/i.test(o)?' rec':'')+"' data-j='"+j+"' title='"+escQ((y.labels&&y.labels[j])||o)+"'><span class='bt'>"+whoHtml(blobOpt((y.labels&&y.labels[j])||o))+"</span></button>";}).join('')+"</div><button type='button' class='bfree' aria-expanded='false'>"+(y.draft?'Change it':'or answer in your own words')+"</button>";
3217
3217
  h+="<form class='bfx"+(os.length?' hidden':'')+"'><input placeholder='"+(y.draft?'what to change':'your answer')+"' aria-label='"+(y.draft?'what to change in the reply':'your answer to '+escQ(nm))+"'><button type='submit'>"+(y.draft?'Redraft':'Send')+"</button></form>";
3218
3218
  if(home&&y.more)h+="<span class='bmore'>+"+y.more+" more question"+(y.more>1?'s':'')+" after this one</span>";
3219
3219
  return h;}