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 +11 -0
- package/agents.mjs +69 -10
- package/handover.mjs +1 -1
- package/home.mjs +38 -2
- package/package.json +2 -2
- package/server.mjs +2 -1
- package/ui.mjs +1 -1
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
|
|
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
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
|
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),
|
|
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.
|
|
4
|
-
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice
|
|
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)+" ›</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;}
|