symbiot 0.63.0 → 0.63.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,12 @@
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.1
6
+
7
+ - **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".
8
+ - **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.
9
+ - **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.
10
+
5
11
  ## 0.63.0
6
12
 
7
13
  - **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
@@ -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,15 +1104,17 @@ 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);
1116
1118
  // Whether a proposed list can be turned on without asking: { ok } or { why }.
1117
1119
  function allowlistInWork(path) {
1118
1120
  let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return { why: "no list" }; }
@@ -1145,7 +1147,47 @@ function autoAllow(path) {
1145
1147
  }
1146
1148
  // Every run that stopped asking for its list, now: on start and every so often, so
1147
1149
  // 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; }
1150
+ // A question whose answer the agent already knows isn't the user's: when its own
1151
+ // recommended option is a step it takes itself (not 👤), and nothing in the question or
1152
+ // that option is an approval, goes out, costs money, touches an account or a machine, or
1153
+ // can't be undone, Symbiot picks it and the agent carries on. Everything else still comes to the user.
1154
+ 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;
1155
+ function autoPick(q) {
1156
+ const rec = (q.options || []).filter((o) => /\(recommended\b/i.test(o));
1157
+ if (rec.length !== 1 || yourStep(rec[0])) return "";
1158
+ return OUTWARD.test(`${q.q} ${q.context || ""} ${rec[0]}`) ? "" : rec[0];
1159
+ }
1160
+ function autoDecide(path) {
1161
+ if (!path || runningHandoff(path)) return null;
1162
+ const picks = agentQuestions(path, "").questions.map((q) => ({ q: q.q, a: autoPick(q) })).filter((x) => x.a);
1163
+ if (!picks.length) return null;
1164
+ 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 });
1165
+ return r && r.ok ? { decided: picks.length } : null;
1166
+ }
1167
+ // A lane whose run has ended with unticked tasks still in TASKS.md starts again by
1168
+ // itself, so open work never just sits there. Only while it's getting somewhere: after
1169
+ // the first continue, a lane starts again only if more tasks got ticked since the last
1170
+ // one, so a lane stuck on its questions doesn't loop. Parked, busy or blocked lanes are left alone.
1171
+ const CONTINUED = new Map(); // path -> ticked count when it was last continued
1172
+ const CONTINUE_AFTER = 2 * 60000;
1173
+ function continueLanes({ now = Date.now(), run = runHandoff, runs = loadRuns } = {}) {
1174
+ const out = [];
1175
+ for (const path of [...new Set(runs().map((r) => r.path).filter(Boolean))]) {
1176
+ try {
1177
+ if (runningHandoff(path) || isParked(path)) continue;
1178
+ const md = readSymbiot(path, "TASKS.md"), open = (md.match(/^\s*(?:[-*+]|\d+[.)])\s*\[ \]/gm) || []).length;
1179
+ if (!open) { CONTINUED.delete(path); continue; }
1180
+ let ended = 0; try { ended = statSync(join(path, ".symbiot", "agent.log")).mtimeMs; } catch { continue; }
1181
+ if (now - ended < CONTINUE_AFTER) continue;
1182
+ const ticked = (md.match(/^\s*(?:[-*+]|\d+[.)])\s*\[[xX]\]/gm) || []).length;
1183
+ if (CONTINUED.has(path) && ticked <= CONTINUED.get(path)) continue;
1184
+ if (blockedAgain(path, handoffCmd())) continue;
1185
+ const e = run(path); if (e && e.id && !e.busy && !e.blocked) { CONTINUED.set(path, ticked); out.push(path); }
1186
+ } catch {}
1187
+ }
1188
+ return out;
1189
+ }
1190
+ 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
1191
  // Every agent reaches the owner's knowledge folders from the start; an ops run (no repo
1150
1192
  // of its own) their repos too, the way a person at their desk would.
1151
1193
  function withScope(tmpl, repoPath) {
@@ -1263,4 +1305,4 @@ function agentsList() {
1263
1305
  }).concat(earlierRuns());
1264
1306
  }
1265
1307
 
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 };
1308
+ export { 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/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.1",
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)