symbiot 0.63.5 → 0.63.7

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/demo.mjs ADDED
@@ -0,0 +1,171 @@
1
+ // symbiot — A Symbiot with made-up work in it, to show: Acme, a company that doesn't
2
+ // exist, its six repos with a few weeks of git, four agents each asking a question
3
+ // with two answers, tasks, what arrived in its inbox, GitHub, WhatsApp and LinkedIn,
4
+ // its week written up, and a Marketing draft. Nothing in it is anyone's real work, so
5
+ // it can be captured and posted. `symbiot capture Symbiot` (capture.mjs) starts one in
6
+ // a throwaway home, captures it and stops it, instead of your own app with your work in
7
+ // it. Ported from the marketing lane's seed.py, which made the first tray (9 October).
8
+ //
9
+ // seedDemo(home): fills that home. startDemo(): a throwaway home, seeded, and Symbiot
10
+ // started in it as a sandbox (sandbox.mjs: no AI keys of yours, its own port, never an
11
+ // update), no window: { url, stop } or { error }. stop() quits it and deletes the home.
12
+ import { join } from "node:path";
13
+ import { mkdirSync, writeFileSync, appendFileSync, readFileSync, existsSync, mkdtempSync, rmSync, realpathSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { fileURLToPath } from "node:url";
17
+ import { createServer } from "node:net";
18
+ import { sandboxEnv } from "./sandbox.mjs";
19
+
20
+ const HERE = fileURLToPath(new URL(".", import.meta.url));
21
+ const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch { return ""; } })();
22
+ const DAY = 86400000;
23
+ const REPOS = {
24
+ storefront: ["# Storefront\nAcme's web shop: product pages, cart and checkout.\n", "ts", ["Add guest checkout", "Cache product images at the edge", "Fix cart total rounding on discounts", "Move checkout to the new payments page", "Tidy the product grid on small screens", "Release 2.4.0"]],
25
+ "billing-api": ["# Billing API\nInvoices, subscriptions and payment webhooks for Acme.\n", "py", ["Retry failed webhooks with backoff", "Add proration for mid-cycle upgrades", "Invoice PDFs in the customer's currency", "Release 1.9.0"]],
26
+ "mobile-app": ["# Acme Mobile\nThe iOS and Android app.\n", "kt", ["Push notifications for order updates", "Dark mode on every screen", "Offline basket", "Fix login loop on Android 15"]],
27
+ "docs-site": ["# Docs\nAcme's public docs.\n", "md", ["Getting started in five minutes", "API reference from the OpenAPI spec", "Search across every page"]],
28
+ "data-pipeline": ["# Data pipeline\nNightly sales and stock reports.\n", "py", ["Load stock levels hourly", "Weekly sales report to the team", "Drop the old CSV export"]],
29
+ "design-system": ["# Design system\nAcme's shared components.\n", "tsx", ["Button and input tokens", "Accessible colour contrast", "Date picker"]],
30
+ };
31
+ const QUESTIONS = {
32
+ storefront: ["Send the price-change email to the 1,240 customers on the old plan?", "It's drafted and goes out in your name, so it waits for your OK.", "🤖 Agent: send it on Monday at 09:00 (recommended)", "🤖 Agent: hold it until the new pricing page is live"],
33
+ "billing-api": ["Refund the duplicate charge on order 4471 now?", "A webhook ran twice on Tuesday, so the customer paid twice for one order.", "🤖 Agent: refund the second charge and tell the customer (recommended)", "🤖 Agent: refund it, and add a guard so it can't happen again"],
34
+ "mobile-app": ["Which release goes to the App Store first?", "3.2 is ready and tested; 3.3 adds the offline basket but needs another week.", "🤖 Agent: ship 3.2 today, 3.3 next week (recommended)", "🤖 Agent: wait and ship 3.3 as one release"],
35
+ "docs-site": ["Publish the new getting-started guide?", "It's checked against the current API and every example runs.", "🤖 Agent: publish it now (recommended)", "🤖 Agent: publish it with Monday's release notes"],
36
+ };
37
+ const DONE = {
38
+ storefront: "Apple Pay is in checkout and tested. The price-change email is drafted and waits for your OK.",
39
+ "billing-api": "Found why order 4471 was charged twice: the webhook ran twice. The refund waits for your OK.",
40
+ "mobile-app": "3.2 passed every test on iOS and Android and is ready to submit.",
41
+ "docs-site": "The getting-started guide is written and every example in it runs.",
42
+ };
43
+ const TASKS = [
44
+ ["storefront", "Add Apple Pay to checkout", false], ["storefront", "Lazy-load the reviews block on product pages", false], ["storefront", "Fix the 404 on old category links", true],
45
+ ["billing-api", "Add a guard so one webhook can never charge twice", false], ["billing-api", "Send a receipt email on every refund", false],
46
+ ["mobile-app", "Ship 3.2 to the App Store", false], ["mobile-app", "Fix the crash on rotate in the order screen", false],
47
+ ["docs-site", "Add a page for the new refunds API", false], ["data-pipeline", "Alert in chat when the nightly job fails", false], ["design-system", "Publish the date picker", false],
48
+ ];
49
+ const WATCH = { gmail: ["a1b2c3d4", "Inbox", "https://mail.google.com/mail/u/0/#inbox"], github: ["b2c3d4e5", "GitHub notifications", "https://github.com/notifications"], whatsapp: ["c3d4e5f6", "WhatsApp", "https://web.whatsapp.com/"], linkedin: ["d4e5f6a7", "LinkedIn", "https://www.linkedin.com/notifications/"] };
50
+ const NEWS = [
51
+ ["github", 25, "acme/billing-api · CI failed on main: 2 tests in webhooks"], ["github", 70, "acme/storefront · Pull request #212 approved: guest checkout"], ["github", 140, "acme/mobile-app · Release 3.2 build passed"],
52
+ ["gmail", 35, "Supplier: the new stock feed goes live on Monday"], ["gmail", 95, "Accountant: September invoices are ready to check"], ["gmail", 180, "Payments provider: your payout is on its way"],
53
+ ["whatsapp", 50, "Design team: the new banners are in the shared folder"], ["linkedin", 120, "3 people viewed your profile"], ["linkedin", 300, "Your post on guest checkout has 41 reactions"],
54
+ ];
55
+ const BRIEFS = [
56
+ ["github", 20, 3, "billing-api's CI failed on main: 2 webhook tests. That's the one that needs you.\nThe guest checkout PR is approved and the 3.2 build passed: nothing to do."],
57
+ ["gmail", 30, 3, "The accountant needs you to check September's invoices.\nThe stock feed and the payout are only news."],
58
+ ["whatsapp", 45, 1, "The design team's new banners are in: nothing waits on you."],
59
+ ["linkedin", 110, 2, "Your guest checkout post is doing well. Nothing needs a reply."],
60
+ ];
61
+ const WEEK = `**Storefront** (main focus): I shipped 2.4.0 this week.
62
+ - **Checkout:** guest checkout is live, and checkout moved to the new payments page.
63
+ - **Speed:** product images are cached at the edge, and the grid is tidier on small screens.
64
+ - **Fixes:** the cart total no longer rounds wrong on discounts.
65
+
66
+ **Billing API:**
67
+ - Failed webhooks now retry with backoff, and mid-cycle upgrades are prorated.
68
+ - Invoice PDFs come in the customer's currency. Released 1.9.0.
69
+ - Still open: a webhook ran twice on Tuesday; the guard against it is next.
70
+
71
+ **Acme Mobile:**
72
+ - Order updates arrive as push notifications, and every screen has dark mode.
73
+ - The Android 15 login loop is fixed. 3.2 is ready for the App Store.
74
+
75
+ **Docs, data and design:**
76
+ - A five-minute getting-started guide and an API reference built from the spec.
77
+ - Stock levels load hourly, and the weekly sales report goes to the team.
78
+ - Buttons and inputs share tokens, with accessible contrast.
79
+ `;
80
+ const POST = `# Guest checkout is live
81
+ product: Storefront
82
+ platform: linkedin
83
+
84
+ ## Post
85
+ You can now buy from Acme without making an account.
86
+
87
+ Guest checkout went live this week: pick what you want, pay with your card or Apple Pay, and you're done. No password to remember, no sign-up form in the way.
88
+
89
+ Since we turned it on, 1 in 3 orders has used it.
90
+
91
+ What's the one step you'd remove from checkout?
92
+
93
+ #Ecommerce #ProductUpdate
94
+
95
+ ## Notes
96
+ From this week's storefront release (2.4.0).
97
+ `;
98
+
99
+ // The same made-up history every time: a small seeded random, not Math.random.
100
+ function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 2 ** 32; }; }
101
+ const writeJson = (f, o) => writeFileSync(f, JSON.stringify(o, null, 2));
102
+
103
+ function seedDemo(home, { now = Date.now() } = {}) {
104
+ const C = join(home, ".config", "symbiot"), code = join(home, "code"), rand = rng(4), hex = (n) => Array.from({ length: n }, () => Math.floor(rand() * 16).toString(16)).join("");
105
+ mkdirSync(C, { recursive: true });
106
+ writeFileSync(join(home, ".gitconfig"), "[user]\n\tname = Sam Rivera\n\temail = sam@acme.example\n[init]\n\tdefaultBranch = main\n");
107
+ const env = { ...process.env, HOME: home, GIT_CONFIG_GLOBAL: join(home, ".gitconfig"), GIT_TERMINAL_PROMPT: "0" };
108
+ const git = (args, extra = {}) => { const r = spawnSync("git", args, { env: { ...env, ...extra }, encoding: "utf8", timeout: 20000 }); if (r.status !== 0) throw new Error(`git ${args[0]}: ${(r.stderr || r.error || "").toString().trim().split("\n").pop()}`); };
109
+ for (const [name, [readme, ext, commits]] of Object.entries(REPOS)) {
110
+ const d = join(code, name); if (existsSync(join(d, ".git"))) continue;
111
+ mkdirSync(join(d, "src"), { recursive: true });
112
+ git(["init", "-q", "-b", "main", d]);
113
+ writeFileSync(join(d, "README.md"), readme);
114
+ for (const f of ["index", "routes", "models", "utils"]) writeFileSync(join(d, "src", `${f}.${ext}`), `// ${f}\n`);
115
+ commits.forEach((msg, i) => {
116
+ const at = new Date(now - (commits.length - i) * Math.round(DAY * (0.6 + rand() * 0.5))).toISOString();
117
+ appendFileSync(join(d, "CHANGES.md"), `- ${msg}\n`);
118
+ git(["-C", d, "add", "-A"]); git(["-C", d, "commit", "-q", "-m", msg], { GIT_AUTHOR_DATE: at, GIT_COMMITTER_DATE: at });
119
+ });
120
+ }
121
+ // four agents, each with its question and what it said
122
+ writeJson(join(C, "runs.json"), Object.entries(QUESTIONS).map(([name, [q, why, a, b]], i) => {
123
+ const s = join(code, name, ".symbiot"); mkdirSync(s, { recursive: true });
124
+ writeFileSync(join(s, "QUESTIONS.md"), `# Questions\n\n## Questions\n### ${q}\n${why}\n- ${a}\n- ${b}\n`);
125
+ writeFileSync(join(s, "agent.log"), JSON.stringify({ type: "result", subtype: "success", result: DONE[name] }) + "\n");
126
+ return { path: join(code, name), name, startedAt: now - (i + 1) * 3600000 };
127
+ }));
128
+ writeJson(join(C, "tasks.json"), TASKS.map(([repo, text, done], i) => ({ id: hex(12), text, repo, done, ts: now - (i + 2) * 7200000, ...(done ? { review: true, reviewAt: now - 3600000 } : {}) })));
129
+ // what arrived: Watch's sources, what's new in each, and a line on each that says what needs you
130
+ const watches = Object.values(WATCH).map(([id, name, url]) => ({ id, name, url, every: 525600, added: now - 5 * DAY, last: now, seen: [], checked: now - 600000, cleared: 0, marksUnread: false }));
131
+ const news = NEWS.map(([w, min, text]) => ({ id: hex(8), watch: WATCH[w][0], name: WATCH[w][1], ts: now - min * 60000, text, href: WATCH[w][2] }));
132
+ const briefs = BRIEFS.map(([w, min, count, text]) => ({ id: hex(8), watch: WATCH[w][0], name: WATCH[w][1], ts: now - min * 60000, count, text }));
133
+ writeJson(join(C, "watch.json"), { watches, news, briefs });
134
+ // set up already: its AI, its agent (a stand-in that does nothing: no agent of yours runs on Acme), its folders, its links
135
+ writeJson(join(C, "config.json"), { apiKey: "sk-ant-demo-not-a-real-key", agentCmd: `"${process.execPath}" -e "" "{prompt}"`, scanRoots: [code], watchBrief: true,
136
+ linked: Object.fromEntries(Object.entries(WATCH).map(([k, [id]]) => [k, { at: now - 5 * DAY, watch: id, hosts: k === "linkedin" ? ["linkedin.com"] : [] }])),
137
+ seenVersion: VERSION, lastSeenVersion: VERSION, onboarding: { pending: false, step: "done", skipped: [], done: now - 5 * DAY } });
138
+ mkdirSync(join(C, "weeks"), { recursive: true });
139
+ writeFileSync(join(C, "weeks", `${new Date(now).toISOString().slice(0, 10)}.md`), WEEK);
140
+ // Marketing: a lane of its own, with a post waiting for your OK
141
+ const MK = join(C, "marketing"); mkdirSync(join(MK, "drafts", "storefront"), { recursive: true });
142
+ writeFileSync(join(MK, "README.md"), "# Marketing\n"); writeFileSync(join(MK, ".gitignore"), ".symbiot/\n");
143
+ git(["init", "-q", "-b", "main", MK]); git(["-C", MK, "add", "-A"]); git(["-C", MK, "commit", "-qm", "Marketing: a lane of its own"]);
144
+ writeFileSync(join(MK, "drafts", "storefront", "guest-checkout.md"), POST);
145
+ return { home, code };
146
+ }
147
+
148
+ const freePort = () => new Promise((r) => { const s = createServer().listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => r(p)); }); s.on("error", () => r(0)); });
149
+ // The names it shows, for a capture to blur the way it would anyone's (none of them is real).
150
+ const DEMO_NAMES = ["Sam Rivera"];
151
+ async function startDemo({ bin = join(HERE, "index.mjs"), wait = 30000, now = Date.now() } = {}) {
152
+ let home = "";
153
+ try { home = realpathSync(mkdtempSync(join(tmpdir(), "symbiot-demo-"))); seedDemo(home, { now }); }
154
+ catch (e) { if (home) rmSync(home, { recursive: true, force: true }); return { error: `Couldn't make the made-up Symbiot to capture: ${(e && e.message) || e}` }; }
155
+ const port = await freePort();
156
+ const app = spawn(process.execPath, [bin, "app"], { env: { ...sandboxEnv(home, process.env, port || 7381), SYMBIOT_NO_OPEN: "1" }, stdio: ["ignore", "pipe", "pipe"] });
157
+ let out = "", gone = false; app.on("exit", () => { gone = true; });
158
+ const stop = async () => {
159
+ if (!gone) { app.kill("SIGTERM"); for (let i = 0; i < 40 && !gone; i++) await new Promise((r) => setTimeout(r, 100)); if (!gone) app.kill("SIGKILL"); }
160
+ rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
161
+ };
162
+ const url = await new Promise((done) => {
163
+ const look = (d) => { out = (out + d).slice(-20000); const m = out.replace(/\x1b\[[0-9;]*m/g, "").match(/http:\/\/127\.0\.0\.1:\d+\/\?t=[\w-]+/); if (m) done(m[0]); };
164
+ app.stdout.on("data", look); app.stderr.on("data", (d) => { out = (out + d).slice(-20000); });
165
+ app.on("exit", () => done("")); setTimeout(() => done(""), wait);
166
+ });
167
+ if (!url) { await stop(); return { error: `The made-up Symbiot didn't start: ${out.trim().split("\n").pop() || "no address within " + wait / 1000 + " s"}` }; }
168
+ return { url, home, stop };
169
+ }
170
+
171
+ export { seedDemo, startDemo, DEMO_NAMES, REPOS as DEMO_REPOS };
package/guard.mjs CHANGED
@@ -8,15 +8,16 @@
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, or Symbiot's own keys (secrets.json: your AI's
11
- // key and the app's token); change Symbiot's own settings
11
+ // key and the app's token); change Symbiot's own settings. A public key (~/.ssh/*.pub)
12
+ // can be read: it's made to be handed out
12
13
  // - open the user's running Symbiot in a browser (a check uses a sandbox copy)
13
14
  // - in a sandboxed repo run (agents.mjs sandboxFor), edit or write a file outside its
14
15
  // repo and your folders (SYMBIOT_WRITES): its commands are held there by the sandbox
15
16
  // judge(tool, input, { cwd, home }) → null (go ahead) or { why }.
16
- import { resolve, join, relative } from "node:path";
17
+ import { resolve, join, relative, dirname, basename } from "node:path";
17
18
  import { homedir } from "node:os";
18
19
  import { execFileSync } from "node:child_process";
19
- import { readFileSync } from "node:fs";
20
+ import { readFileSync, realpathSync, statSync } from "node:fs";
20
21
  import { fileURLToPath } from "node:url";
21
22
 
22
23
  const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/secrets.json"];
@@ -25,6 +26,20 @@ const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, ho
25
26
  const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
26
27
  const SCP_VALUE = /^-[346ABCOpqRrTv]*[iFoPSJcl]$/; // an scp option (alone or after flags, -rpi) whose value is the next word
27
28
  function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
29
+ // A public key is made to be handed out, so reading one goes ahead (the Read tool, or a plain
30
+ // cat or head): a file directly in ~/.ssh named <name>.pub, that's still a .pub there once
31
+ // links are followed (a x.pub pointing at a private key isn't). Writing or copying one, and
32
+ // everything else in ~/.ssh, stays stopped. Ops made a key for the WinLove server and
33
+ // couldn't read its .pub to send to the server's admin (2026-10-12).
34
+ function publicKey(p, home) {
35
+ const dir = join(home, ".ssh");
36
+ if (dirname(p) !== dir || !/^[\w.@+-]+\.pub$/.test(basename(p))) return false;
37
+ try { const real = realpathSync(p); return dirname(real) === realpathSync(dir) && real.endsWith(".pub") && statSync(real).isFile(); } catch { return false; }
38
+ }
39
+ // in a shell, only a path named outright: no glob, variable or quoting trick that could
40
+ // reach a private key too (~/.ssh/*, ~/.ssh/$K)
41
+ const PUB_READ = /^(cat|less|more|head|tail|grep|rg|base64|xxd|strings)$/;
42
+ const PLAIN = /^(~|\$HOME|\$\{HOME\})?[\w.@+\/-]+$/;
28
43
  function symbiotConfig(p, home) { const d = join(home, ".config", "symbiot"); return p === join(d, "config.json") || p === join(d, "licence-revoked.json"); }
29
44
  // Symbiot's own installed files (where this guard runs from): never edited by an agent, so
30
45
  // it can't be asked to take out its own limits or rules. Ghost AI's owner key lets its own
@@ -99,7 +114,10 @@ function judgeBash(cmd, { cwd, home, branch }) {
99
114
  // counts: `scp ~/.ssh/id_rsa host:` stays blocked. The argena lane's approved deploy
100
115
  // (`scp -i ~/.ssh/oracle_key server/x.py ubuntu@host:…`) was blocked (2026-10-08).
101
116
  const own = w[0] === "scp" ? SCP_VALUE : null;
102
- 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" }; }
117
+ for (const p of w.slice(1).filter((a, i, all) => !a.startsWith("-") && !(own && own.test(all[i - 1] || "")))) {
118
+ const abs = expand(unquote(p), home, cwd);
119
+ if (secretPath(abs, home) && !(PUB_READ.test(w[0]) && PLAIN.test(unquote(p)) && publicKey(abs, home))) return { why: "your keys and cloud credentials stay yours" };
120
+ }
103
121
  }
104
122
  if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/(config|secrets)\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
105
123
  }
@@ -116,7 +134,7 @@ function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch
116
134
  const p = input.file_path || input.path || input.notebook_path;
117
135
  if (!p) return null;
118
136
  const abs = expand(p, home, cwd);
119
- if (secretPath(abs, home)) return { why: "your keys and cloud credentials stay yours" };
137
+ if (secretPath(abs, home) && !(t === "Read" && publicKey(abs, home))) return { why: "your keys and cloud credentials stay yours" };
120
138
  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && symbiotConfig(abs, home)) return { why: "Symbiot's own settings are yours to change" };
121
139
  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && selfFile(abs)) return { why: "Symbiot doesn't edit its own code" };
122
140
  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && Array.isArray(writes) && writes.length && !writes.some((d) => under(abs, d))) return { why: "this run writes only in its repo and your folders (its sandbox), not " + p };
package/handback.mjs CHANGED
@@ -33,7 +33,7 @@ import { remember, actIn } from "./mind.mjs";
33
33
  import { newsSince, watchState, isMail, isSignedOut } from "./watch.mjs";
34
34
  import { inboxMail } from "./mail.mjs";
35
35
  import { desktopNotify } from "./desktop.mjs";
36
- import { laneMap } from "./scan.mjs";
36
+ import { laneMap, me } from "./scan.mjs";
37
37
 
38
38
  const AWAIT_FILE = join(CONFIG_DIR, "awaiting.json");
39
39
  const RUNS_DIR = join(CONFIG_DIR, "drafts"); // runs of their own (act-…) and drafted replies
@@ -99,20 +99,33 @@ function collectWaits(d, { map, running = runningHandoff, now = Date.now() }) {
99
99
  // subject from someone it went to (anyone, when it doesn't say whom). From
100
100
  // Watch: an inbox row (sender, subject, preview) that shows its subject, new
101
101
  // since it was sent: Gmail lists a thread in the inbox once someone answers.
102
- function answers(w, m) {
102
+ // Never the user's own mail: not from one of `mine` (their addresses), and not a
103
+ // row whose newest message is theirs. Gmail starts a row with who's on the thread,
104
+ // the newest last ("Cale, Jono, me 5, Fwd: winlove setup, …"), so a thread the user
105
+ // just wrote on lists "me" last. That row started an ops agent on a reply Jono
106
+ // hadn't sent: it was Garth's own email to him (2026-10-12).
107
+ function answers(w, m, mine = []) {
103
108
  if (!(m.ts > w.sent)) return false;
104
109
  const want = subjectCore(w.subject); if (want.length < 4) return false;
105
110
  if (m.subject !== undefined) {
106
111
  if (subjectCore(m.subject) !== want) return false;
112
+ if (m.from && mine.includes(String(m.from.addr || "").toLowerCase())) return false;
107
113
  const to = addrs(w); return !to.length || (!!m.from && to.includes(m.from.addr));
108
114
  }
109
- return String(m.text || "").toLowerCase().replace(/\s+/g, " ").includes(want.slice(0, 80));
115
+ if (m.mine || m.from === "you") return false;
116
+ const text = String(m.text || "").toLowerCase().replace(/\s+/g, " "), at = text.indexOf(want.slice(0, 80));
117
+ return at >= 0 && lastSender(text.slice(0, at)) !== "me";
110
118
  }
119
+ // The newest sender in what a row says before its subject ("cale, jono, me 5, fwd: "
120
+ // → "me"): the last name, past the message count and any Re:/Fwd:.
121
+ const lastSender = (lead) => (lead.replace(/(?:\b(?:re|fwd?|aw|sv|antw|wg)\s*(?:\[\d+\])?\s*:\s*)+$/i, "").replace(/[\s,·:;|-]*(?:\(?\d+\)?)?[\s,·:;|-]*$/, "").split(/[\s,·]+/).pop() || "");
122
+ // The user's own addresses: those under Email, and git's.
123
+ const myAddrs = () => { let m = {}; try { m = loadConfig().mail || {}; } catch {} return [...(Array.isArray(m.addresses) ? m.addresses : []), me().email].map((a) => String(a || "").trim().toLowerCase()).filter((a) => a.includes("@")); };
111
124
  // The reply to `w` among what came in, oldest first, or null: { ts, from, subject, via }.
112
- function replyTo(w, { news = [], inbox = [] }) {
113
- const mail = inbox.filter((m) => answers(w, m)).sort((a, b) => a.ts - b.ts)[0];
125
+ function replyTo(w, { news = [], inbox = [], mine = [] }) {
126
+ const mail = inbox.filter((m) => answers(w, m, mine)).sort((a, b) => a.ts - b.ts)[0];
114
127
  if (mail) return { ts: mail.ts, from: (mail.from && (mail.from.name || mail.from.addr)) || "", subject: mail.subject, via: "your inbox on this computer" };
115
- const n = news.filter((x) => answers(w, x)).sort((a, b) => a.ts - b.ts)[0];
128
+ const n = news.filter((x) => answers(w, x, mine)).sort((a, b) => a.ts - b.ts)[0];
116
129
  return n ? { ts: n.ts, from: "", subject: w.subject, via: `Watch (${n.name || "your inbox"})`, text: clipWords(n.text, 300) } : null;
117
130
  }
118
131
 
@@ -151,14 +164,15 @@ function handOn(w, r, { map, act = actIn, notify = desktopNotify, now = Date.now
151
164
  // One pass, from the app every minute: take in new waits, look for their
152
165
  // replies, and hand on the ones that came in. `news`, `inbox`, `act`, `notify`
153
166
  // and `running` can be given (the tests do).
154
- function awaitTick({ map = laneMap(), now = Date.now(), news, inbox, act, notify, running } = {}) {
167
+ function awaitTick({ map = laneMap(), now = Date.now(), news, inbox, mine, act, notify, running } = {}) {
155
168
  const d = loadWaits(), added = collectWaits(d, { map, running, now }), open = d.waits.filter((w) => w.status === "waiting");
156
169
  const replied = [];
157
170
  if (open.length) {
158
171
  const since = Math.min(...open.map((w) => w.sent));
159
172
  if (!news) { const mailWatch = new Set(watchState().watches.filter((x) => isMail(x.url)).map((x) => x.id)); news = newsSince(Math.max(1, (now - since) / 3600000) + 1, now).filter((n) => mailWatch.has(n.watch)); }
160
173
  if (!inbox) { const m = loadConfig().mail || {}; inbox = m.enabled ? inboxMail({ since, sources: Array.isArray(m.sources) ? m.sources : [] }) : []; }
161
- for (const w of open) { const r = replyTo(w, { news, inbox }); if (r) { handOn(w, r, { map, act, notify, now }); replied.push(w); } }
174
+ if (!mine) mine = myAddrs();
175
+ for (const w of open) { const r = replyTo(w, { news, inbox, mine }); if (r) { handOn(w, r, { map, act, notify, now }); replied.push(w); } }
162
176
  }
163
177
  if (added.length || replied.length) saveWaits(d);
164
178
  return { added, replied };
package/handover.mjs CHANGED
@@ -19,7 +19,7 @@ const HANDOVER_MAX = 20000;
19
19
  function handoverRules(lanes = [], here = "") {
20
20
  const mk = lanes.includes(MARKETING) && here !== MARKETING, others = [...new Set(lanes.filter((l) => l && l !== here && l !== MARKETING))].slice(0, MAX_LANES_LISTED);
21
21
  return ["## Hand over what's another lane's job",
22
- `Each agent has its own lane${here ? `: this one is \`${here}\`` : ""}. When something you need is another lane's job, don't stop, and don't ask the user to do it: hand it over. Write \`.symbiot/HANDOFF.md\` with a \`### \` heading naming the lane, then what's needed, self-contained (names, paths, links, ids) and why. Symbiot starts that lane's agent on it, and its result comes back to you in \`.symbiot/ANSWERS.md\`. Carry on with what doesn't depend on it, and don't tick what does.`,
22
+ `Each agent has its own lane${here ? `: this one is \`${here}\`` : ""}. When something you need is another lane's job, don't stop, and don't ask the user to do it: hand it over. Write \`.symbiot/HANDOFF.md\` with a \`### \` heading naming the lane, then what's needed, self-contained (names, paths, links, ids) and why. Symbiot starts that lane's agent on it within 20 seconds, then rewrites the file as a list of what went where, so a HANDOFF.md with no \`### \` section means everything in it was sent (the Agents tab shows how far each lane's agent has got). Its result comes back to you in \`.symbiot/ANSWERS.md\`. Carry on with what doesn't depend on it, and don't tick what does.`,
23
23
  `- \`${OPS}\`: anything outside a repo: this computer (find or install a tool, a JDK, an SDK), accounts and services (cloud, GitHub, DNS, email), the user's connectors.`,
24
24
  ...(mk ? [`- \`${MARKETING}\`: marketing for any product: posts, demo videos and screenshots, launches, landing-page copy, campaigns. It drafts them; the user approves each one's preview, and then it posts or schedules it itself through Symbiot's signed-in browser.`] : []),
25
25
  `- A repo, by its folder name, for work that belongs in that project${others.length ? `: ${others.map((l) => `\`${l}\``).join(", ")}` : ""}.`,