symbiot 0.57.6 → 0.57.9

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,20 @@
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.9
6
+
7
+ - **On Windows, Symbiot is in the Start menu and on your desktop.** Installing puts a Symbiot shortcut with the orb in both (your real desktop, even when it's in OneDrive), and opens Symbiot right away, into setup. The shortcuts start it through a small hidden script, so no black console window flashes up; `symbiot uninstall` takes them away again. Installing on a Windows desktop now opens Symbiot too, and says so in the terminal.
8
+
9
+ ## 0.57.8
10
+
11
+ - **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.
12
+ - **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.
13
+
14
+ ## 0.57.7
15
+
16
+ - **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.
17
+ - **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.
18
+
5
19
  ## 0.57.6
6
20
 
7
21
  - **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/desktop.mjs CHANGED
@@ -5,10 +5,10 @@
5
5
  // log in, so the write-up still happens on days you never open Symbiot.
6
6
  // `symbiot app` then finds that running copy and opens its window.
7
7
  import { fileURLToPath } from "node:url";
8
- import { spawn } from "node:child_process";
8
+ import { spawn, spawnSync } from "node:child_process";
9
9
  import { homedir } from "node:os";
10
10
  import { join, dirname } from "node:path";
11
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync, chmodSync } from "node:fs";
11
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync, chmodSync, rmdirSync } from "node:fs";
12
12
  import { CONFIG_DIR, loadConfig, saveConfig, hasCmd } from "./core.mjs";
13
13
 
14
14
  // The Android app (android/) runs this same code with SYMBIOT_ANDROID_APP=1. Its
@@ -191,9 +191,54 @@ function launcherPath(path, node) {
191
191
  for (const d of [...String(path || "").split(":"), dirname(node)]) if (d && !/node_modules[\\/]\.bin|node-gyp-bin/.test(d) && !seen.has(d)) { seen.add(d); out.push(d); }
192
192
  return out.join(":");
193
193
  }
194
+ // ---- Windows: the Start menu and the desktop -------------------------------------
195
+ // A shortcut in the Start menu and one on the desktop, with the orb (icon.ico). Each
196
+ // runs a small script through wscript (open.vbs), which starts `symbiot open` with
197
+ // its window hidden: node is a console program, and run straight from a shortcut it
198
+ // would flash a black window.
199
+ // Windows' own APPDATA (when it's in this home); the desktop as Windows reports it
200
+ // (often in OneDrive), kept in desktop.txt so uninstalling finds that shortcut again.
201
+ const winDirs = (home = homedir()) => {
202
+ const env = process.env.APPDATA || "", appdata = env && env.toLowerCase().startsWith(home.toLowerCase()) ? env : join(home, "AppData", "Roaming"), own = join(appdata, "Symbiot");
203
+ let desk = ""; try { desk = readFileSync(join(own, "desktop.txt"), "utf8").trim(); } catch {}
204
+ return { own, menu: join(appdata, "Microsoft", "Windows", "Start Menu", "Programs", "Symbiot.lnk"), desk: desk || join(home, "Desktop", "Symbiot.lnk") };
205
+ };
206
+ const ICO_SRC = fileURLToPath(new URL("./icon.ico", import.meta.url));
207
+ // VBScript: "" is a quote inside a string
208
+ const vbsContent = (node, script) => `' Symbiot: start it (or show it) with no console window\r\nCreateObject("WScript.Shell").Run """${node.replace(/"/g, '""')}"" ""${script.replace(/"/g, '""')}"" open", 0, False\r\n`;
209
+ // PowerShell that makes one shortcut: single quotes, '' is a quote inside one
210
+ function shortcutPs(lnk, vbs, ico) {
211
+ const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
212
+ return `$s = (New-Object -ComObject WScript.Shell).CreateShortcut(${q(lnk)}); $s.TargetPath = (Join-Path $env:WINDIR 'System32\\wscript.exe'); $s.Arguments = ${q('"' + vbs + '"')}; $s.IconLocation = ${q(ico + ",0")}; $s.Description = 'Symbiot'; $s.WorkingDirectory = $env:USERPROFILE; $s.Save()`;
213
+ }
214
+ function installWindows({ node, script, home, run }) {
215
+ const d = winDirs(home), vbs = join(d.own, "open.vbs"), ico = join(d.own, "symbiot.ico"), want = vbsContent(node, script);
216
+ let wrote = false;
217
+ try {
218
+ mkdirSync(d.own, { recursive: true });
219
+ if (!existsSync(join(d.own, "desktop.txt")) && home === homedir()) {
220
+ const r = run("powershell", ["-NoProfile", "-NonInteractive", "-Command", "[Environment]::GetFolderPath('Desktop')"]);
221
+ const dir = r && r.status === 0 ? String(r.stdout || "").trim() : "";
222
+ if (dir) { d.desk = join(dir, "Symbiot.lnk"); writeFileSync(join(d.own, "desktop.txt"), d.desk); }
223
+ }
224
+ let had = ""; try { had = readFileSync(vbs, "utf8"); } catch {}
225
+ if (had !== want) { writeFileSync(vbs, want); wrote = true; }
226
+ if (!existsSync(ico)) { writeFileSync(ico, readFileSync(ICO_SRC)); wrote = true; }
227
+ for (const lnk of [d.menu, d.desk]) {
228
+ if (!wrote && existsSync(lnk)) continue;
229
+ try { mkdirSync(join(lnk, ".."), { recursive: true }); } catch {}
230
+ const r = run("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", shortcutPs(lnk, vbs, ico)]);
231
+ if (r && r.status !== 0) return { error: String((r.stderr || r.stdout || "") || "PowerShell couldn't make the shortcut").trim().slice(0, 300) };
232
+ wrote = true;
233
+ }
234
+ } catch (e) { return { error: (e && e.message) || String(e) }; }
235
+ return { written: wrote, file: d.menu, desktop: d.desk };
236
+ }
194
237
  // Writes the entry and icon when they're missing or differ. { written, file } or { skipped }.
195
- function installLauncher({ node = process.execPath, script, home = homedir(), platform = OS, path = process.env.PATH || "" } = {}) {
196
- if (platform !== "linux" || !script) return { skipped: true };
238
+ function installLauncher({ node = process.execPath, script, home = homedir(), platform = OS, path = process.env.PATH || "", run = (c, a) => spawnSync(c, a, { encoding: "utf8", windowsHide: true, timeout: 30000 }) } = {}) {
239
+ if (!script) return { skipped: true };
240
+ if (platform === "win32") return installWindows({ node, script, home, run });
241
+ if (platform !== "linux") return { skipped: true };
197
242
  const file = launcherFile(home), icon = launcherIcon(home), want = launcherContent(node, script, launcherPath(path, node));
198
243
  let wrote = false;
199
244
  try {
@@ -207,8 +252,9 @@ function installLauncher({ node = process.execPath, script, home = homedir(), pl
207
252
  return { written: wrote, file };
208
253
  }
209
254
  function removeLauncher(home = homedir()) {
210
- const gone = [];
211
- for (const f of [launcherFile(home), launcherIcon(home)]) { try { if (existsSync(f)) { unlinkSync(f); gone.push(f); } } catch {} }
255
+ const gone = [], d = winDirs(home);
256
+ for (const f of [launcherFile(home), launcherIcon(home), d.menu, d.desk, join(d.own, "open.vbs"), join(d.own, "symbiot.ico"), join(d.own, "desktop.txt")]) { try { if (existsSync(f)) { unlinkSync(f); gone.push(f); } } catch {} }
257
+ try { rmdirSync(d.own); } catch {}
212
258
  return gone;
213
259
  }
214
260
  function autostartState() { const file = autostartFile(); return { on: existsSync(file), file, ...(OS === "android-app" ? { phone: "app" } : OS === "android" ? { phone: "termux" } : {}) }; }
@@ -226,4 +272,4 @@ function setAutostart(on, script) {
226
272
  } catch (e) { return { ...autostartState(), error: String((e && e.message) || e) }; }
227
273
  }
228
274
 
229
- export { iconSvg, setLauncherLook, launcherPath, launcherFile, launcherIcon, launcherContent, installLauncher, removeLauncher, weeklyCfg, lastSlot, weeklyDue, setWeekly, weeklyState, latestWeek, runWeekly, startWeekly, notifyCmd, desktopNotify, autostartFile, autostartContent, autostartState, setAutostart };
275
+ export { winDirs, vbsContent, shortcutPs, iconSvg, setLauncherLook, launcherPath, launcherFile, launcherIcon, launcherContent, installLauncher, removeLauncher, weeklyCfg, lastSlot, weeklyDue, setWeekly, weeklyState, latestWeek, runWeekly, startWeekly, notifyCmd, desktopNotify, autostartFile, autostartContent, autostartState, setAutostart };
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
- for (const p of w.slice(1).filter((a) => !a.startsWith("-"))) { if (secretPath(expand(p, home, cwd), home)) return { why: "your keys and cloud credentials stay yours" }; }
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/icon.ico ADDED
Binary file
package/index.mjs CHANGED
@@ -360,7 +360,7 @@ async function cmdOpen() {
360
360
  if (p && p.version) { openApp(`http://127.0.0.1:${port}/?t=${cfg.appToken}`); return; }
361
361
  let fd = "ignore"; try { mkdirSync(CONFIG_DIR, { recursive: true }); fd = openSync(join(CONFIG_DIR, "app.log"), "a"); } catch {}
362
362
  const env = { ...process.env }; delete env.SYMBIOT_NO_OPEN;
363
- spawn(process.execPath, [realpathSync(fileURLToPath(import.meta.url)), "app"], { detached: true, stdio: ["ignore", fd, fd], env }).unref();
363
+ spawn(process.execPath, [realpathSync(fileURLToPath(import.meta.url)), "app"], { detached: true, stdio: ["ignore", fd, fd], env, windowsHide: true }).unref();
364
364
  }
365
365
  // symbiot uninstall: everything Symbiot put on this computer, then the program.
366
366
  // --keep-data keeps ~/.config/symbiot (settings, tasks, memory); --yes doesn't ask.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.57.6",
3
+ "version": "0.57.9",
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": {
@@ -48,7 +48,8 @@
48
48
  "postinstall.mjs",
49
49
  "icon.svg",
50
50
  "scanworker.mjs",
51
- "estimate.mjs"
51
+ "estimate.mjs",
52
+ "icon.ico"
52
53
  ],
53
54
  "engines": {
54
55
  "node": ">=18"
package/postinstall.mjs CHANGED
@@ -1,19 +1,22 @@
1
- // npm install -g symbiot runs this: Symbiot in your app menu, with its icon, so you
2
- // never need a terminal to start it (desktop.mjs installLauncher), and then it opens,
3
- // so setup starts right away. Only for a global install on a desktop, and it never
4
- // fails the install. npm hides what install scripts print, so the line goes straight
5
- // to your terminal.
1
+ // npm install -g symbiot runs this: Symbiot where you start apps (the app menu on
2
+ // Linux, the Start menu and the desktop on Windows), with its icon, so you never need
3
+ // a terminal to start it (desktop.mjs installLauncher), and then it opens, so setup
4
+ // starts right away. Only for a global install on a desktop, and it never fails the
5
+ // install. npm hides what install scripts print, so the line goes straight to your
6
+ // terminal (its console on Windows).
6
7
  import { realpathSync, writeFileSync } from "node:fs";
7
8
  import { spawn } from "node:child_process";
8
9
  import { fileURLToPath } from "node:url";
9
- const say = (s) => { try { writeFileSync("/dev/tty", s + "\n"); } catch { console.log(s); } };
10
+ const WIN = process.platform === "win32";
11
+ const say = (s) => { try { writeFileSync(WIN ? "CONOUT$" : "/dev/tty", s + "\n"); } catch { console.log(s); } };
10
12
  try {
11
13
  if (process.env.npm_config_global === "true" && !process.env.SYMBIOT_NO_LAUNCHER) {
12
14
  const script = realpathSync(fileURLToPath(new URL("./index.mjs", import.meta.url)));
13
15
  const { installLauncher } = await import("./desktop.mjs");
14
16
  const r = installLauncher({ script });
15
- const desktop = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) && !process.env.CI && !process.env.SYMBIOT_NO_OPEN;
16
- if (desktop) { spawn(process.execPath, [script, "open"], { detached: true, stdio: "ignore" }).unref(); say("\nSymbiot is installed and opening now. Next time, open it from your app menu: no terminal needed.\n"); }
17
- else if (r.file) say("\nSymbiot is in your app menu: open it from there, no terminal needed.\n");
17
+ const desktop = (WIN || process.platform === "darwin" || !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)) && !process.env.CI && !process.env.SYMBIOT_NO_OPEN;
18
+ const where = WIN ? "the Start menu or your desktop" : "your app menu";
19
+ if (desktop) { spawn(process.execPath, [script, "open"], { detached: true, stdio: "ignore", windowsHide: true }).unref(); say(`\nSymbiot is installed and opening now. Next time, open it from ${where}: no terminal needed.\n`); }
20
+ else if (r.file) say(`\nSymbiot is in ${where}: open it from there, no terminal needed.\n`);
18
21
  }
19
22
  } catch {}
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
- else { try { server.listen(0, "127.0.0.1"); } catch {} }
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
- const lines = body.replace(/^\s*\n/, "").replace(/\s+$/, "").split("\n");
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 };