symbiot 0.57.6 → 0.57.8
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 +10 -0
- package/guard.mjs +12 -1
- package/package.json +1 -1
- package/server.mjs +13 -2
- package/tasks.mjs +1 -1
- package/watch.mjs +15 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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.57.8
|
|
6
|
+
|
|
7
|
+
- **An approved deploy with your server key goes through.** The membrane read `scp -i ~/.ssh/oracle_key …` as copying your key, when the key is only what scp signs in with (`ssh -i` already passed). So argena's deploy to the game server stayed blocked even after you approved it twice. Now scp's sign-in options don't count as files it copies. Copying a key or credentials themselves (`scp ~/.ssh/id_rsa host:`) is still stopped.
|
|
8
|
+
- **Agents check Symbiot in a copy of their own.** Every brief now says to see Symbiot working in a sandbox (`symbiot app --fresh`), and the membrane stops a browser or screenshot pointed at your running app. That's what closed your window five times on 2026-10-08. Reading its API with curl is still fine.
|
|
9
|
+
|
|
10
|
+
## 0.57.7
|
|
11
|
+
|
|
12
|
+
- **A reply you sent yourself leaves Home.** If you paste a drafted reply in and send it yourself, then say so, its card goes, and so does a draft its agent noted as sent. Before, 0.57.6 kept asking for your OK on Frikkie's reply, already sent at 14:10, and showed the agent's note "Sent by the user… shortened to…" as if it were part of the reply.
|
|
13
|
+
- **One Symbiot, even mid-update.** A launch that found Symbiot still restarting after an update used to start a second copy on another port, with its own window, and every timer then ran twice. Now it waits for the restarted app, opens that window and leaves it at that.
|
|
14
|
+
|
|
5
15
|
## 0.57.6
|
|
6
16
|
|
|
7
17
|
- **See a drafted reply before you OK it.** A card like "Draft: Messaging, 1 new notification waits for your OK" used to say "The reply to Frikkie is ready for you to send" without showing the reply, so to check it you had to open LinkedIn. Now the card is "Reply to Frikkie on LinkedIn", with the reply itself on it, as it will look there (a LinkedIn message, a WhatsApp bubble or an email) and with none of the agent's notes. **Go ahead** sends it from your account in Symbiot's signed-in browser, exactly as shown. **Skip** drops it. **Change it** takes what you'd change in your own words: the agent redrafts, and the new version shows on the same card. The card no longer says the agent can't send it.
|
package/guard.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// - delete outside the agent's own folder, or wipe a disk
|
|
9
9
|
// - sudo, or pipe something from the internet into a shell
|
|
10
10
|
// - read SSH keys or cloud credentials; change Symbiot's own settings
|
|
11
|
+
// - open the user's running Symbiot in a browser (a check uses a sandbox copy)
|
|
11
12
|
// - in a sandboxed repo run (agents.mjs sandboxFor), edit or write a file outside its
|
|
12
13
|
// repo and your folders (SYMBIOT_WRITES): its commands are held there by the sandbox
|
|
13
14
|
// judge(tool, input, { cwd, home }) → null (go ahead) or { why }.
|
|
@@ -20,6 +21,8 @@ import { fileURLToPath } from "node:url";
|
|
|
20
21
|
const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials"];
|
|
21
22
|
const under = (p, d) => p === d || p.startsWith(d.endsWith("/") ? d : d + "/");
|
|
22
23
|
const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, home).replace(/^\$HOME(?=\/|$)/, home).replace(/^\$\{HOME\}(?=\/|$)/, home));
|
|
24
|
+
const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
|
|
25
|
+
const SCP_VALUE = /^-[346ABCOpqRrTv]*[iFoPSJcl]$/; // an scp option (alone or after flags, -rpi) whose value is the next word
|
|
23
26
|
function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
|
|
24
27
|
function symbiotConfig(p, home) { return p === join(home, ".config", "symbiot", "config.json"); }
|
|
25
28
|
|
|
@@ -55,11 +58,19 @@ function judgeBash(cmd, { cwd, home, branch }) {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
if (/^(cat|less|more|head|tail|cp|scp|base64|xxd|strings|grep|rg|sed|awk)$/.test(w[0])) {
|
|
58
|
-
|
|
61
|
+
// scp's sign-in options take a value (`-i ~/.ssh/key` is the key it signs in with, as
|
|
62
|
+
// ssh's is, never a file it copies): those aren't what it reads. Only what it copies
|
|
63
|
+
// counts: `scp ~/.ssh/id_rsa host:` stays blocked. The argena lane's approved deploy
|
|
64
|
+
// (`scp -i ~/.ssh/oracle_key server/x.py ubuntu@host:…`) was blocked (2026-10-08).
|
|
65
|
+
const own = w[0] === "scp" ? SCP_VALUE : null;
|
|
66
|
+
for (const p of w.slice(1).filter((a, i, all) => !a.startsWith("-") && !(own && own.test(all[i - 1] || "")))) { if (secretPath(expand(p, home, cwd), home)) return { why: "your keys and cloud credentials stay yours" }; }
|
|
59
67
|
}
|
|
60
68
|
if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/config\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
|
|
61
69
|
}
|
|
62
70
|
if (/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(c)) return { why: "running a script straight from the internet" };
|
|
71
|
+
// a browser on the user's running Symbiot: each page it opened counted as a new window and
|
|
72
|
+
// closed theirs, five times in four minutes (2026-10-08). Its API (curl) is fine to read.
|
|
73
|
+
if (BROWSER.test(c) && /\b(127\.0\.0\.1|localhost):7391\b/.test(c)) return { why: "that's the user's running Symbiot (their window and data): check it in a copy of your own, `symbiot app --fresh`" };
|
|
63
74
|
return null;
|
|
64
75
|
}
|
|
65
76
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.57.
|
|
3
|
+
"version": "0.57.8",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server.mjs
CHANGED
|
@@ -527,12 +527,23 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
527
527
|
});
|
|
528
528
|
server.on("error", (e) => {
|
|
529
529
|
// Stable port busy (an older instance still exiting during an update, or a
|
|
530
|
-
// second app): retry briefly, then fall back to a random port.
|
|
530
|
+
// second app): retry briefly, then fall back to a random port unless it's Symbiot's.
|
|
531
531
|
if (e && e.code === "EADDRINUSE" && tries < (RELAUNCH ? 40 : 8)) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
|
|
532
532
|
// after an update, the window only ever looks for the stable port: a copy on another
|
|
533
533
|
// port would serve nobody and linger (one did, for hours), so it bows out
|
|
534
534
|
else if (RELAUNCH) process.exit(0);
|
|
535
|
-
|
|
535
|
+
// still busy: if it's a Symbiot (one restarting after an update answers late), open its
|
|
536
|
+
// window and bow out. A copy on another port runs every timer a second time: one started
|
|
537
|
+
// just as 0.57.6 relaunched itself, and two apps ran side by side (2026-10-08).
|
|
538
|
+
else (process.env.SYMBIOT_FORCE_NEW ? Promise.resolve(null) : askRunningApp("ping", { port: PORT, token: TOKEN, ms: 2500 })).then((p) => {
|
|
539
|
+
if (p && p.version) {
|
|
540
|
+
const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`, how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
|
|
541
|
+
console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
|
|
542
|
+
console.log(how ? c.d(` Opened the existing window (${/^[aeiou]/.test(how) ? "an" : "a"} ${how}).`) : c.d(" Open that URL in your browser."));
|
|
543
|
+
process.exit(0);
|
|
544
|
+
}
|
|
545
|
+
try { server.listen(0, "127.0.0.1"); } catch {}
|
|
546
|
+
});
|
|
536
547
|
});
|
|
537
548
|
server.listen(PORT, "127.0.0.1");
|
|
538
549
|
checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
|
package/tasks.mjs
CHANGED
|
@@ -635,7 +635,7 @@ function buildTasksMd(name, ctx, list) {
|
|
|
635
635
|
const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
|
|
636
636
|
const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
|
|
637
637
|
for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
|
|
638
|
-
L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "- If an item has numbered steps (1), 2), …), say `Step N: …` as you start each one, so Home shows how far along you are.", "- If the user says to drop an item (in ANSWERS.md, say), delete its line here: Symbiot closes it, so it isn't sent again. Don't delete one for any other reason.", "");
|
|
638
|
+
L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "- If an item has numbered steps (1), 2), …), say `Step N: …` as you start each one, so Home shows how far along you are.", "- To see Symbiot itself working (its Home, a layout, a card), run a copy of your own: `node index.mjs app --fresh` from this repo, or `symbiot app --fresh`. Never point a browser, a screenshot or a proxy at the user's running Symbiot (127.0.0.1:7391): it's their window and their data.", "- If the user says to drop an item (in ANSWERS.md, say), delete its line here: Symbiot closes it, so it isn't sent again. Don't delete one for any other reason.", "");
|
|
639
639
|
L.push(...handoverRules(ctx.lanes || [], name), ...HANDBACK);
|
|
640
640
|
L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Workdesk, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
|
|
641
641
|
`- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then a line of context, then exactly ${OPTIONS_SHOWN} options as \`- \` bullets, the one you recommend first, marked \`(recommended)\`. Symbiot shows only the first ${OPTIONS_SHOWN}; the user can always answer in their own words.`,
|
package/watch.mjs
CHANGED
|
@@ -561,22 +561,33 @@ const PLATFORM = { chat: "WhatsApp", social: "LinkedIn" };
|
|
|
561
561
|
function replyOf(md) {
|
|
562
562
|
const s = String(md || ""), m = s.match(/^##\s+The reply\s*$/m); if (!m) return null;
|
|
563
563
|
let body = s.slice(m.index + m[0].length); const nx = body.search(/^##\s/m); if (nx >= 0) body = body.slice(0, nx);
|
|
564
|
-
|
|
564
|
+
let lines = body.replace(/^\s*\n/, "").replace(/\s+$/, "").split("\n");
|
|
565
565
|
const to = /^\s*To:\s*/i.test(lines[0] || "") ? lines.shift().replace(/^\s*To:\s*/i, "").trim() : "";
|
|
566
|
+
// a run's note that it went out ("Sent by the user on … at 2:10 PM, shortened to: …") isn't the reply
|
|
567
|
+
const at = lines.findIndex((l) => SENT_NOTE.test(l)), sent = at >= 0; if (sent) lines = lines.slice(0, at);
|
|
566
568
|
const text = lines.join("\n").trim();
|
|
567
|
-
return text ? { to, text } : null;
|
|
569
|
+
return text ? { to, text, ...(sent ? { sent: true } : {}) } : null;
|
|
570
|
+
}
|
|
571
|
+
const SENT_NOTE = /^\s*(sent|posted|replied)\b.*\b(by|on|at)\b/i;
|
|
572
|
+
// The user sent it themselves (they pasted it in, say) and said so: the newest answer in
|
|
573
|
+
// its ANSWERS.md ("Pasted and sent, so this one's done", then "Done: the user did it").
|
|
574
|
+
// Frikkie's reply went out at 14:10, and 0.57.6 still put it on Home for an OK (2026-10-08).
|
|
575
|
+
function sentByUser(answers) {
|
|
576
|
+
const blocks = String(answers || "").split(/^###\s+/m).slice(1);
|
|
577
|
+
const last = blocks.slice(-2).join("\n");
|
|
578
|
+
return /\b(pasted and sent|i sent|sent it|already sent|it'?s sent|was sent|sent, so)\b/i.test(last);
|
|
568
579
|
}
|
|
569
580
|
const readDraftFile = (dir, f) => { try { return readFileSync(join(dir, ".symbiot", f), "utf8"); } catch { return ""; } };
|
|
570
581
|
const draftMeta = (dir) => { try { return JSON.parse(readDraftFile(dir, "handoff.json")) || {}; } catch { return {}; } };
|
|
571
582
|
const isDraftDir = (dir) => { const h = draftMeta(dir); return !!(h.draft || (h.env && h.env.SYMBIOT_DRAFT)); };
|
|
572
583
|
// A draft waiting for the user: its run drafted it (the task ticked) and wrote the
|
|
573
|
-
// reply down, and it hasn't been sent or skipped. { id, dir, platform, kind, to, text } or null.
|
|
584
|
+
// reply down, and it hasn't been sent (by Symbiot, or by the user, who said so) or skipped. { id, dir, platform, kind, to, text } or null.
|
|
574
585
|
function draftCard(dir) {
|
|
575
586
|
const h = draftMeta(dir); if (!h.draft && !(h.env && h.env.SYMBIOT_DRAFT)) return null;
|
|
576
587
|
const d = h.draft || {}; if (d.state === "skipped" || d.state === "sent") return null;
|
|
577
588
|
const md = readDraftFile(dir, "TASKS.md");
|
|
578
589
|
if (!/^\s*-\s*\[x\]\s*Draft a reply/im.test(md)) return null; // still drafting, sending, or never got that far
|
|
579
|
-
const r = replyOf(md); if (!r) return null;
|
|
590
|
+
const r = replyOf(md); if (!r || r.sent || sentByUser(readDraftFile(dir, "ANSWERS.md"))) return null; // gone out already: nothing to OK
|
|
580
591
|
const kind = d.kind || (/^# Draft a reply: .*(LinkedIn)/m.test(md) ? "social" : /WhatsApp/.test(md) ? "chat" : "mail");
|
|
581
592
|
const platform = d.platform || PLATFORM[kind] || (md.match(/^# Draft a reply: (.+)$/m) || [])[1] || "your mail";
|
|
582
593
|
return { id: dir.split(/[\\/]/).pop(), dir, platform, kind, to: r.to, text: r.text };
|