symbiot 0.57.4 β†’ 0.57.6

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,25 @@
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.6
6
+
7
+ - **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.
8
+ - **Every waiting task says why.** On Home, a waiting task now says in plain words what it's waiting on: its lane's agent is on it now, it's queued behind that lane's run (named, with what it's doing), it waits on your answer or your OK (a tap opens that card on the Workdesk), or it's blocked (no folder, no agent, parked, or its start failed, and why). One that's sat for more than a few minutes with nothing in its way, or whose run ended without finishing it, is flagged instead of sitting there quietly.
9
+ - **Tasks start by themselves.** A waiting task goes to its lane's agent as soon as that lane is free, without pressing Go; lanes run side by side. A "πŸ€– Agent: …" task (the ops lane) starts as an agent run of its own instead of waiting forever; a "πŸ‘€ You" one is left to you. `"autoStart": false` in settings leaves it all to Go.
10
+ - **Commit and push are an agent's job.** When a CI run fails and the fix is local git work (stage, commit, pull, push), the lane's agent does it and only asks for your OK on the Workdesk, with the files, before it pushes. It no longer lands under "Only the user can do" asking you to paste git commands into a terminal.
11
+ - **See a post the way it will look.** Under Marketing, **Preview** shows each draft its agent wrote as the platform's own post: for LinkedIn, your name, headline and photo, the text with its real line breaks, cut with "…see more" where the feed cuts it, hashtags, and its picture or video inline. The agent's notes are folded away below it, never mixed into the post: each draft keeps the post under `## Post` and the notes under `## Notes`, so the preview and what goes out are the same text.
12
+ - **Approving the preview is your only step in a post.** **Approve** and **Skip** sit next to the preview. Approved, Marketing's agent posts it, or schedules it for its date, through Symbiot's browser, already signed in to LinkedIn, with exactly the text you approved. If the post changes afterwards, it asks you again. A Marketing question no longer asks you to schedule, paste or attach posts on a platform you've linked: that's the agent's option now, once you approve. Only signing in stays yours.
13
+ - **What's linked is never asked for again.** With LinkedIn signed in in Symbiot's browser, no card asks you to sign in, link it, make a developer app or do it by hand: those options become the agent's ("post it through Symbiot's browser, already signed in to LinkedIn, once you approve its preview"). It asks you to sign in again only when a real attempt found the session expired, and says so. Every agent's brief now says to prefer the linked session over an API or app that needs you.
14
+ - **A post to approve, lit.** Every draft Marketing's agent wrote that you haven't approved or skipped shows under Marketing's **Needs you** as "a post to approve", with **Preview** one click away, and lights Marketing's orb on Home.
15
+ - **Reports end with what to do about them.** A report's own "Top 3 next" (or next steps, recommendations) shows at its end as up to 4 ideas you tick straight onto the Workdesk, in that report's lane, with a box to ask Symbiot about the report right there. A draft report (a post preview, say) gets **Approve** and **Reject** instead.
16
+ - **"Sent to" names the right lane.** Answering a Marketing question said "Sent to Paperclip Steve". It now names the lane the answer was saved in, and an answer's note no longer lingers under another project's cards.
17
+ - **Approve stages only the task's own files**, runs git in the background with a longer timeout, and says plainly what went wrong instead of `ETIMEDOUT`.
18
+
19
+ ## 0.57.5
20
+
21
+ - **Symbiot's window stops closing by itself.** Since 0.55.1, opening a second Symbiot window closes the older one, so you don't end up with a pile of them. But an agent taking a screenshot of Home in a headless browser counted as a new window too, so each screenshot closed the one you were using. Today that looked like Symbiot crashing again and again while the symbiot agent was checking Home's layout. The app itself never stopped. A headless browser no longer counts as a window, and the window you're in never closes itself: if a newer one opens, it says so and offers "Use this one".
22
+ - **Urgent work goes first.** When you hand over something urgent (the app crashing, "urgent", "comes first", or a word in capitals), Symbiot parks every other lane that has work going or waiting. Their current run finishes, but nothing new starts. If the lane's own agent is on routine work, it stops (its changes stay in the folder) and the urgent task starts at once. Once that run is done, the parked lanes pick up again in the order they were parked. Lanes you parked yourself stay parked. Home's reply says what got parked, and urgent tasks get a section of their own at the top of the brief, whatever kind of task they are.
23
+
5
24
  ## 0.57.4
6
25
 
7
26
  - **Home's layout holds with agents at work.** The agents at work used to sit among the section orbs. That pushed the headings off their own orbs ("Projects" over Todo, "Resources" over Workdesk), drew "Watching" on top of "Areas", scattered what you watch, and squeezed each agent's name into a narrow column ("Ste / ve"). Now they have a band of their own under "Needs you": a small orb each, the lane's name in full, and its step, what it's on and the time left under it. The sections keep their own places, each heading by its orbs. When the window is too small for that and Home falls back to its free layout, no heading is drawn on another.
@@ -24,6 +43,7 @@ What each Symbiot release brought, newest first. When Approve bumps the version,
24
43
  - **Your Claude subscription on Windows.** Claude Code installs as `claude.cmd` on Windows, which Symbiot didn't find, so it fell back to asking for a key. And Symbiot's instructions now reach Claude Code in a file instead of on the command line, which Windows caps at about 8,000 characters.
25
44
  - **`symbiot --version` and `symbiot --help` work.** Both ran the weekly write-up instead.
26
45
 
46
+
27
47
  ## 0.57.0
28
48
 
29
49
  - **Setup, before anything else.** A new Symbiot opens straight into setup: what it is (it watches, it works, it asks), your AI (your Claude subscription, found by itself), your work (your projects, found while you read), your coding agent (one click), every app you use (each connected, or marked "I don't use it"; you can't go on until they're all decided), and an optional folder of your documents. Then a short "finding your way" and Home. Where you are is kept, so closing it resumes there; Settings β†’ Setup β†’ Run setup again walks it again.
package/README.md CHANGED
@@ -783,8 +783,10 @@ and nothing else. **It never posts by itself**, and it doesn't schedule.
783
783
  - **You approve each one.** The drafts wait under **Marketing**, a section of its
784
784
  own (its orb shows on Home once posts can be drafted, lit while drafts wait on
785
785
  you), each tagged with the product it's about, with **Approve**,
786
- **Edit** and **Skip**. Approve copies the post to your clipboard and opens
787
- LinkedIn's share box: you paste it and post it yourself.
786
+ **Edit** and **Skip**. Approve is your only step: it hands the post to
787
+ Marketing's agent, which posts it on LinkedIn through Symbiot's signed-in
788
+ browser, with exactly the text you approved and its picture or video, and
789
+ checks it's there.
788
790
  - **Pictures and videos.** Each draft says in a line what picture or short video
789
791
  would show it best. Add one of yours (**Add picture or video**: PNG, JPG, GIF,
790
792
  MP4, MOV, WebM), or click **From a page** and give a web address (your app on
@@ -812,7 +814,7 @@ and nothing else. **It never posts by itself**, and it doesn't schedule.
812
814
  ```bash
813
815
  symbiot post # draft this week's 3 posts
814
816
  symbiot post list # the drafts waiting on you
815
- symbiot post approve <id> # copy it, and get LinkedIn's share box (--open opens it)
817
+ symbiot post approve <id> # approve it: Marketing's agent posts it
816
818
  symbiot post edit <id> "text" # change a draft's words
817
819
  symbiot post add <id> <file> # put a picture or video of yours on it
818
820
  symbiot post page <id> <page> # a picture of a web page on it (--clip: a short video)
@@ -888,13 +890,37 @@ launches or pricing; any agent can hand work to it too, as `marketing`.
888
890
  one's code and docs are. Every post opens with a strong hook, says plainly what
889
891
  the product is and states its end goal; where the end goal isn't written down, it
890
892
  drafts one and asks you to confirm it.
891
- - **It never posts**, publishes, schedules or sends, and never signs in as you.
892
- Its work waits for your OK like any project's (**Approve** commits it in its
893
- folder), and its questions reach you like any agent's.
893
+ - **Each draft holds the post apart from its notes.** A draft is a `.md` with a
894
+ `# ` title, then `product:`, `platform:` (`linkedin`), `when:` (`2026-10-13 08:00`,
895
+ when it's scheduled) and `media:` (its picture or video, kept next to it) lines,
896
+ then `## Post` with exactly the text that goes out, and `## Notes` with the agent's
897
+ reasoning and sources. A draft written before this, with the post between two
898
+ `---` lines, still reads the same way.
899
+ - **You see the post, not the file.** Under **What its agent drafted**, **Preview**
900
+ shows each draft the way the platform will: for LinkedIn, your name, headline and
901
+ photo (`"profile": { "name", "headline", "avatar" }` in
902
+ `~/.config/symbiot/config.json`, else your git name and initials), the text with its
903
+ own line breaks, cut with **…see more** where LinkedIn's feed cuts it (about 210
904
+ characters; click it for the rest), hashtags in blue, and its picture or video
905
+ inline. The agent's notes are folded away under **Its notes**, never in the post.
906
+ - **Approving the preview is your only step.** **Approve** and **Skip** sit next to
907
+ it. Approved, the agent is told in its `ANSWERS.md` and starts: it posts the post,
908
+ or schedules it in the platform's own scheduler for its `when:`, through Symbiot's
909
+ browser, already signed in to the platforms you linked, with exactly the text you
910
+ approved, then checks it's there. The approval goes with that text: if the post
911
+ changes afterwards, it asks you again. It never asks you to post, schedule, paste
912
+ or attach anything yourself. A question that does, on a platform you've linked,
913
+ shows as the agent's option ("πŸ€– Agent: …, once you approve its preview"). Only
914
+ signing in, for a platform that isn't linked or has signed out, is yours. It never
915
+ posts what you haven't approved, and never signs in as you.
916
+ - **Its work waits for your OK like any project's** (**Approve** commits it in its
917
+ folder), and its questions reach you like any agent's. Answering one says
918
+ **Sent to Marketing**, the lane it went to.
894
919
  - **On Home** it's an orb among your projects, lit amber when something there needs
895
920
  you: its questions (which also show as blobs under **Needs you**), its work
896
- waiting for your OK, and the week's drafts. Click it and its page opens with
897
- those lit at the top.
921
+ waiting for your OK, the week's drafts, and every post its agent drafted that
922
+ you haven't approved or skipped yet, as **a post to approve** with **Preview** one
923
+ click away. Click it and its page opens with those lit at the top.
898
924
 
899
925
  ### What a run hands back: facts to remember, replies to wait for, reports
900
926
 
@@ -920,7 +946,13 @@ Every brief also tells the agent what else it can leave in `.symbiot/`:
920
946
  - **A report** (findings, an audit, a plan, a pitch): any `.md` named for what it
921
947
  is, with a `# ` title. **Reports** in the app lists every one your runs left,
922
948
  in your repos and in runs of their own, newest first, unread ones marked; click
923
- one to read it there, tables and all. Home shows how many are unread.
949
+ one to read it there, tables and all. Home shows how many are unread. Each one
950
+ ends with what to do about it: up to 4 ideas drawn from its own "next" section
951
+ (**Top 3 next**, **Next steps**, **Recommendations**…), ticked straight onto the
952
+ Workdesk in its lane with **Add to the Workdesk**, and a box to ask Symbiot about
953
+ it in place. A draft for your OK (a report whose title or file says draft or
954
+ preview, a post preview say) gets **Approve** and **Reject** instead: its agent is
955
+ told in its `ANSWERS.md`, and goes ahead once you approve.
924
956
  `symbiot reports` lists them in a terminal, and `symbiot reports <id>` prints
925
957
  one. Symbiot's own files there (TASKS, QUESTIONS, ANSWERS, HANDOFF, SKIPPED)
926
958
  and the briefs handed in (`BRIEF*.md`) aren't reports.
package/agents.mjs CHANGED
@@ -10,7 +10,7 @@ import { randomBytes, createHash } from "node:crypto";
10
10
  import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
11
11
  import { parseRun, lastRunText, readRunLog, briefPlan, stepOf, runLine } from "./work.mjs";
12
12
  import { noteDuration, kindOf } from "./estimate.mjs";
13
- import { parseFacts, leftToYou } from "./handover.mjs";
13
+ import { parseFacts, leftToYou, gitWork, agentsGitWork, agentsPosting, linkedAsks, linkedChore, PUSH_OK } from "./handover.mjs";
14
14
  import { scanRoots, repoPathMap } from "./scan.mjs";
15
15
  import { knowledgeFolders } from "./knowledge.mjs";
16
16
 
@@ -35,7 +35,7 @@ function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(migrateC
35
35
  // Save the template ("" clears it). Either way the legacy `ide` key goes.
36
36
  function setHandoffCmd(cmd) {
37
37
  const cfg = loadConfig(); const v = String(cmd || "").trim();
38
- if (v) cfg.agentCmd = v; else delete cfg.agentCmd;
38
+ if (v) { cfg.agentCmd = v; delete cfg.agentOff; } else { delete cfg.agentCmd; cfg.agentOff = true; } // agentOff: you cleared it, so Home doesn't pick one for you again
39
39
  delete cfg.ide; saveConfig(cfg);
40
40
  return { ok: true, cmd: cfg.agentCmd || "" };
41
41
  }
@@ -277,6 +277,7 @@ function runHandoff(repoPath, { force = false } = {}) {
277
277
  if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
278
278
  const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
279
279
  try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} // the step's done (or this is Start it anyway): this is the run it waited for
280
+ noteUntracked(repoPath);
280
281
  const lock = join(repoPath, ".symbiot", LOCK);
281
282
  const own = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null, boxEnv = sandboxEnv(box);
282
283
  const env = own || boxEnv ? { ...(own || {}), ...(boxEnv || {}) } : null;
@@ -286,9 +287,11 @@ function runHandoff(repoPath, { force = false } = {}) {
286
287
  noteBlocked(repoPath, tmpl, e.startedAt, code);
287
288
  setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
288
289
  startHeldTasks(repoPath); // tasks sent while it ran land now; start on them as that Send would have
290
+ try { urgentDone(repoPath, e.id); } catch {} // the urgent run's done: the lanes it parked carry on
289
291
  }, env);
290
292
  if (!e) return null;
291
293
  e.handoff = true;
294
+ try { if (urgentState()) noteUrgentRun(repoPath, e.id); } catch {}
292
295
  if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt, owner: process.pid })); } catch {}
293
296
  noteRun(e);
294
297
  return e;
@@ -309,6 +312,79 @@ function parkLane(path, on) {
309
312
  saveConfig(cfg);
310
313
  return { ok: true, parked: !!on };
311
314
  }
315
+ // ---- urgent work goes first -------------------------------------------------
316
+ // "symbiot keeps crashing" waited behind a routine run in its own lane (the Home
317
+ // layout, step 5 of 6) and 5 more tasks, and Garth parked argena and steve by hand
318
+ // (2026-10-08). Urgent is a crash, "urgent", "comes first" or a word in capitals.
319
+ // Then urgentFirst parks every other lane with work going or waiting (a run already
320
+ // going there finishes, as parking does: the end of a run is the safe point, and a
321
+ // test stopped halfway would be void), stops the urgent lane's own routine run (its
322
+ // changes stay in the folder, and the next run carries on from them) so the urgent
323
+ // one starts now, and when that run ends, urgentDone unparks those lanes in their
324
+ // old order and starts the ones with work waiting. Lanes you parked stay parked.
325
+ const CAPS_OK = /^(README|TODO|FIXME|HTML|JSON|YAML|HTTP|HTTPS|OAUTH|CHANGELOG|TASKS|ANSWERS|QUESTIONS|HANDOFF|REMEMBER|AWAITING|SKIPPED|LICENSE|CLAUDE|AGENTS|NOTE|ASAP|SAST|UTC|GPU|CPU|RAM|USB|SDK|JDK|API|URL|CSS|DNS|SSH|SMS|PDF|PNG|SVG|MCP|LLM|CLI|ENV|PATH|HOME|WIP|ETA)$/;
326
+ function isUrgent(text) {
327
+ const t = String(text || "");
328
+ if (/\burgent\b|\b(asap|right now|drop everything)\b|\b(do (this|it) first|comes? first|goes? first|first thing|takes? priority|before (anything|everything) else)\b/i.test(t)) return true;
329
+ if (/\bcrash(es|ing|ed)\b|\bkeeps? (closing|quitting|dying)\b|\b(won'?t|doesn'?t|can'?t) (start|open|launch)\b/i.test(t)) return true;
330
+ // capitals: a word of 4+ letters, not a file's name (TASKS.md) nor a usual acronym
331
+ return (t.match(/(?<![\w./-])[A-Z]{4,}(?![\w.])/g) || []).some((w) => !CAPS_OK.test(w));
332
+ }
333
+ const urgentState = () => { const u = loadConfig().urgent; return u && Array.isArray(u.paths) && u.paths.length ? u : null; };
334
+ function saveUrgent(u) { const cfg = loadConfig(); if (u) cfg.urgent = u; else delete cfg.urgent; saveConfig(cfg); }
335
+ const hasOpenUrgent = (md) => String(md || "").split("\n").some((l) => /^\s*-\s*\[ \]/.test(l) && isUrgent(l));
336
+ function stopRun(path) {
337
+ const r = runningHandoff(path); if (!r || !r.pid) return false;
338
+ try { process.kill(-r.pid, "SIGTERM"); } catch { try { process.kill(r.pid, "SIGTERM"); } catch { return false; } } // its own process group (track: detached)
339
+ return true;
340
+ }
341
+ // lanes: the paths to consider ({ name: path }); returns { parked: [names], stopped }
342
+ function urgentFirst(path, { lanes = null, stop = stopRun, running = runningHandoff } = {}) {
343
+ if (!path) return { parked: [], stopped: false };
344
+ const u = urgentState() || { at: Date.now(), paths: [], parked: [], runs: [] };
345
+ if (!u.paths.includes(path)) u.paths.push(path);
346
+ if (u.parked.includes(path)) { u.parked = u.parked.filter((p) => p !== path); parkLane(path, false); } // urgent itself now
347
+ const all = lanes || Object.fromEntries(loadRuns().map((r) => [String(r.name || r.path.split("/").pop()), r.path]));
348
+ const parked = [], already = parkedPaths();
349
+ for (const [name, p] of Object.entries(all)) {
350
+ if (!p || u.paths.includes(p) || already.includes(p)) continue; // yours stay yours
351
+ if (!running(p) && !readSymbiot(p, HELD)) continue; // nothing going or waiting there
352
+ parkLane(p, true); u.parked.push(p); parked.push(name);
353
+ }
354
+ // its own lane: a routine run stops so this one starts now; one that's already on urgent work keeps going
355
+ const busy = running(path), onIt = busy && (u.runs.includes(busy.id) || hasOpenUrgent(readSymbiot(path, "TASKS.md")));
356
+ const stopped = !!busy && !onIt && stop(path);
357
+ saveUrgent(u);
358
+ return { parked, stopped };
359
+ }
360
+ // the urgent lane's run started: when it ends, the parked work resumes (runHandoff's exit)
361
+ function noteUrgentRun(path, id) { const u = urgentState(); if (u && u.paths.includes(path) && id && !u.runs.includes(id)) { u.runs.push(id); saveUrgent(u); } }
362
+ function urgentDone(path, id, { running = runningHandoff, start = runHandoff } = {}) {
363
+ const u = urgentState(); if (!u || !u.paths.includes(path) || !u.runs.includes(id)) return null;
364
+ u.paths = u.paths.filter((p) => p !== path);
365
+ if (u.paths.some((p) => running(p))) { saveUrgent(u); return null; } // another urgent lane is still at it
366
+ saveUrgent(null);
367
+ return resumeParked(u.parked, start);
368
+ }
369
+ function resumeParked(paths, start = runHandoff) {
370
+ const resumed = [];
371
+ for (const p of paths) { // in the order they were parked
372
+ if (!parkedPaths().includes(p)) continue; // you unparked it meanwhile
373
+ parkLane(p, false);
374
+ if (/^\s*-\s*\[ \]/m.test(readSymbiot(p, HELD) || readSymbiot(p, "TASKS.md"))) { try { const e = startHeldTasks(p) || start(p); if (e && e.id) resumed.push(p); } catch {} }
375
+ }
376
+ return { unparked: paths, resumed };
377
+ }
378
+ // The app restarted under an urgent run (its exit never came), or it never started:
379
+ // the parked lanes don't wait forever.
380
+ const URGENT_MAX = 6 * 3600 * 1000;
381
+ function urgentSweep({ running = runningHandoff, now = Date.now(), start = runHandoff } = {}) {
382
+ const u = urgentState(); if (!u) return null;
383
+ const ended = u.runs.length > 0 && !u.paths.some((p) => running(p));
384
+ if (!ended && now - (u.at || 0) < URGENT_MAX) return null;
385
+ saveUrgent(null);
386
+ return resumeParked(u.parked, start);
387
+ }
312
388
  // The agent the handoff command runs, when it isn't on this computer (the CLI
313
389
  // was uninstalled, or PATH lost it): its name, else "". Then no task can start.
314
390
  function agentMissing(tmpl = handoffCmd()) {
@@ -379,10 +455,14 @@ function takenUp(path, end) {
379
455
  if (!path.startsWith(ACT_DIR)) return false;
380
456
  const t = firstTask(path); return !!t && loadRuns().some((r) => r.path !== path && r.path.startsWith(ACT_DIR) && Number(r.startedAt) > end && sameTask(firstTask(r.path), t));
381
457
  }
458
+ const draftRun = (path) => { try { const h = JSON.parse(readSymbiot(path, "handoff.json")) || {}; return !!(h.draft || (h.env && h.env.SYMBIOT_DRAFT)); } catch { return false; } };
382
459
  function needsOf(path, { final = "", waiting = null, end = Date.now(), now = Date.now() } = {}) {
383
460
  if (now - end > NEEDS_FOR) return null;
384
461
  const n = waiting ? { kind: "step", what: clipWords(String(waiting.step || "").replace(/^\s*πŸ‘€\s*(You\s*(\([^)]*\))?:)?\s*/, "").trim(), 240), check: "", label: "", waiting: true } : leftToYou(final) || readNeeds(path, final);
385
462
  if (!n || !n.what) return null;
463
+ if (n.kind === "approve" && draftRun(path)) return null; // a drafted reply: its card shows it and sends it (watch.mjs draftCard)
464
+ // signing in to, linking or doing by hand on a platform that's linked: the agent's, not yours
465
+ if (n.kind !== "approve" && linkedChore(`${n.label} ${n.what} ${n.check}`, linkedIds())) return null;
386
466
  const key = digest(n.what);
387
467
  if (loadSettled()[path] === key || takenUp(path, end)) return null;
388
468
  return { ...n, key };
@@ -401,10 +481,10 @@ const readKey = (path, final) => digest(path + "\n" + String(final || "").trim()
401
481
  function readNeeds(path, final, { file = READ_FILE } = {}) {
402
482
  if (!String(final || "").trim()) return null;
403
483
  const r = loadRead(file)[readKey(path, final)];
404
- return r && r.what ? { kind: r.kind === "approve" ? "approve" : "step", what: r.what, check: r.check || "", label: r.label || "", read: true } : null;
484
+ return r && r.what ? { kind: r.kind === "approve" || gitWork(r.what + " " + (r.check || "")) ? "approve" : "step", what: r.what, check: r.check || "", label: r.label || "", read: true } : null;
405
485
  }
406
486
  const READ_SYSTEM = `You read the last message a coding agent left when its run ended, and say whether it leaves the user something to do before the work is finished.
407
- That's either something waiting for their OK before it happens (sending, posting, deleting, paying, closing, merging, publishing) or a step only they can take (signing in, a password or a 2FA code, sudo, a phone or a cable, a token from a provider's console, a choice that's theirs to make).
487
+ That's either something waiting for their OK before it happens (sending, posting, deleting, paying, closing, merging, publishing) or a step only they can take (signing in, a password or a 2FA code, sudo, a phone or a cable, a token from a provider's console, a choice that's theirs to make). Local git work (staging, committing, pulling, pushing) is never a step only they can take: an agent does it, so it's "approve", their OK before a push.
408
488
  Not: work that's done, a summary of what changed, an idea they might like, changes waiting for review or Approve in Symbiot, a connector to sign in to again, or a question already asked in QUESTIONS.md.
409
489
  Reply with JSON only, no markdown fence: {"needs": false} or {"needs": true, "kind": "approve" or "step", "what": "the sentence that says so, in its own words, under 240 characters", "check": "what it says to check first, or empty", "label": "what it is in 1 to 3 words, or empty"}.`;
410
490
  function parseRead(raw) {
@@ -537,6 +617,7 @@ function waitNote(w) {
537
617
  // for, start once it changes. The app calls this every so often. Returns the jobs started.
538
618
  function startWaiting() {
539
619
  const started = [];
620
+ try { urgentSweep(); } catch {}
540
621
  for (const r of loadRuns()) {
541
622
  let w = null; try { w = JSON.parse(readSymbiot(r.path, WAITING)); } catch {}
542
623
  if (!w || !w.rerun || !Array.isArray(w.files) || !(w.files.length || w.cmd) || runningHandoff(r.path)) continue;
@@ -592,6 +673,26 @@ function runningHandoff(path) {
592
673
  } catch {}
593
674
  return null;
594
675
  }
676
+ // ---- the untracked files that were there before the agent ran ---------------
677
+ // Approve stages only a run's own changes (tasks.mjs taskPaths), so the first run
678
+ // since the last approve notes the untracked files already in the folder:
679
+ // .symbiot/untracked.json, { at, files }. Approve forgets it once it commits.
680
+ // Listed in the background: in a big tree (scratch's 170k files) that takes a
681
+ // while, and a file the agent writes meanwhile is newer than `at`, so it counts
682
+ // as the agent's anyway. Not a git repo: no note.
683
+ const UNTRACKED = "untracked.json";
684
+ function noteUntracked(path) {
685
+ const f = join(path, ".symbiot", UNTRACKED); if (existsSync(f)) return;
686
+ const at = Date.now(), out = [], p = spawn("git", ["-C", path, "ls-files", "--others", "--exclude-standard", "-z"], { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
687
+ p.on("error", () => {}); p.stdout.on("data", (d) => out.push(d));
688
+ p.on("close", (code) => {
689
+ if (code !== 0 || existsSync(f)) return;
690
+ const files = Buffer.concat(out).toString("utf8").split("\0").filter((x) => x && !x.startsWith(".symbiot/"));
691
+ try { mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, JSON.stringify({ at, files })); } catch {}
692
+ });
693
+ }
694
+ function untrackedBefore(path) { try { const u = JSON.parse(readSymbiot(path, UNTRACKED)); return u && Array.isArray(u.files) && Number.isFinite(u.at) ? u : null; } catch { return null; } }
695
+ function forgetUntracked(path) { try { unlinkSync(join(path, ".symbiot", UNTRACKED)); } catch {} }
595
696
  // Send to repos while an agent is still running in the folder doesn't rewrite
596
697
  // the TASKS.md it's working from: the new brief waits in TASKS.next.md and
597
698
  // replaces TASKS.md once that agent exits. Returns true if it was held.
@@ -815,7 +916,11 @@ const readSymbiot = (path, f) => { if (f === "agent.log") return readRunLog(join
815
916
  // "## Questions" β†’ "### question", context lines, "- option" bullets;
816
917
  // "## Suggestions" (or Ideas / Follow-ups) β†’ "- idea" bullets. Forgiving: a bare
817
918
  // bullet under Questions is a question with no options.
818
- function parseQuestions(md) {
919
+ // The platforms linked in Symbiot's browser (links.mjs keeps them in config.linked): a
920
+ // πŸ‘€ option to post there, or to sign in or link it again, is the agent's, and doing it
921
+ // by hand isn't offered (handover.mjs agentsPosting, linkedAsks).
922
+ const linkedIds = () => { try { return Object.keys(loadConfig().linked || {}); } catch { return []; } };
923
+ function parseQuestions(md, { linked = linkedIds() } = {}) {
819
924
  const questions = [], suggestions = []; let sec = "q", cur = null;
820
925
  const clip = (s) => clipWords(String(s).trim(), TASK_MAX); // as long as a task holds (addTask), so an idea added in one click arrives whole
821
926
  for (const raw of String(md || "").split(/\r?\n/)) {
@@ -833,7 +938,7 @@ function parseQuestions(md) {
833
938
  if (b) { if (cur) cur.options.push(clip(b[1])); else if (/\?\s*$/.test(b[1])) questions.push({ q: clip(b[1]), context: "", options: [] }); continue; } // a bullet with no "### question" above it is a question only if it actually ends in "?" β€” otherwise it's preamble/prose (a file list, etc.)
834
939
  if (cur) cur.context = clip((cur.context ? cur.context + " " : "") + l);
835
940
  }
836
- return { questions: questions.filter((x) => x.q).slice(0, 20).map((x) => ({ ...x, options: x.options.slice(0, 6) })), suggestions: suggestions.filter(Boolean).slice(0, 10) };
941
+ return { questions: questions.filter((x) => x.q).slice(0, 20).map((x) => ({ ...x, options: agentsPosting(linkedAsks(agentsGitWork(x.options), linked, `${x.q} ${x.context}`), linked).slice(0, 6) })), suggestions: suggestions.filter(Boolean).slice(0, 10) };
837
942
  }
838
943
  // An idea for another project names it first: "[repo: symbiot] Watch GitHub
839
944
  // too" is for the symbiot repo's tasks, whichever repo's agent had it (an agent
@@ -1011,7 +1116,10 @@ function answerQuestions(path, answers, opts = {}) {
1011
1116
  const steps = rows.filter((x) => yourStep(x.a) || holdAnswer(x.a)).map((x) => yourStep(x.a) ? { step: stepText(x.a), named: x.a.split("πŸ€–")[0] }
1012
1117
  : { step: (youOpts(x.q).map(stepText)[0] || x.a).trim(), named: [x.a, ...youOpts(x.q)].join(" ") });
1013
1118
  const granted = [], notes = {};
1014
- let yours = steps.map((s) => s.step).filter(Boolean).filter((st) => { const g = grantFromStep(st, path, notes); if (g) granted.push(...g); return !g; });
1119
+ // local git work picked as the user's step (typed in, or from a QUESTIONS.md written before this): the agent's, with an OK before a push
1120
+ const git = steps.map((s) => s.step).filter(gitWork);
1121
+ if (git.length) try { writeFileSync(join(path, ".symbiot", "ANSWERS.md"), readSymbiot(path, "ANSWERS.md").replace(/\s*$/, "\n") + `\n### Symbiot: that git work is yours to do, not the user's\nDo it yourself: ${git.join(" ")}. Check what's staged first, and ${PUSH_OK.replace("your OK", "the user's OK").replace("it pushes", "you push")}: ask in QUESTIONS.md and stop there.\n_${day}_\n`); } catch {}
1122
+ let yours = steps.map((s) => s.step).filter(Boolean).filter((st) => !gitWork(st)).filter((st) => { const g = grantFromStep(st, path, notes); if (g) granted.push(...g); return !g; });
1015
1123
  if (granted.length) out.granted = granted;
1016
1124
  // A list too wide to turn on by a click: say which rules, tell the agent to ask for
1017
1125
  // less, and don't leave it as the user's step (picking it again would do nothing).
@@ -1082,4 +1190,4 @@ function agentsList() {
1082
1190
  }).concat(earlierRuns());
1083
1191
  }
1084
1192
 
1085
- 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, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, 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 };
1193
+ 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, 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, urgentDone, urgentSweep, urgentState };
package/handover.mjs CHANGED
@@ -21,13 +21,13 @@ function handoverRules(lanes = [], here = "") {
21
21
  return ["## Hand over what's another lane's job",
22
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.`,
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
- ...(mk ? [`- \`${MARKETING}\`: marketing for any product: posts, demo videos and screenshots, launches, landing-page copy, campaigns. It drafts them for the user to approve, and never posts.`] : []),
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(", ")}` : ""}.`,
26
26
  ""];
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. An option where picking it is enough starts with `πŸ€– Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `πŸ‘€ You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it. Whatever waits on the user's OK (sending, posting, deleting, paying, closing) or on a step only they can take goes in QUESTIONS.md as a question, with what they should check first in its context line, never only in your last message: they aren't asked about what your last message says.";
30
+ const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `πŸ‘€ You (only you: <which>):`, e.g. `πŸ‘€ You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a πŸ‘€ step: do it, or hand it over. Local git work (staging, committing, pulling, pushing, in this repo or another lane's) is never a πŸ‘€ step either: do it, or hand it to that repo's lane, and ask only for an OK in QUESTIONS.md before a push, with the files it pushes. Prefer what's already linked: Symbiot's own browser (`~/.config/symbiot/browser`) is signed in to the sites the user linked under Connections, so use that session over any route that needs a new sign-in, an app, a key or their consent (a platform's API, a developer app, OAuth). Check what's linked before you write a πŸ‘€ sign-in or link step, ask them to sign in only when a real attempt found the session expired (and say so), and never offer doing an agent's job by hand. An option where picking it is enough starts with `πŸ€– Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `πŸ‘€ You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it. Whatever waits on the user's OK (sending, posting, deleting, paying, closing) or on a step only they can take goes in QUESTIONS.md as a question, with what they should check first in its context line, never only in your last message: they aren't asked about what your last message says.";
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
@@ -56,6 +56,88 @@ function parseAwaiting(text) {
56
56
  })).filter((x) => x.subject).slice(0, 20);
57
57
  }
58
58
 
59
+ // ---- local git work is an agent's job --------------------------------------------
60
+ // A CI run failed, and the run that looked into it asked the user to paste `git add -A
61
+ // && git commit … && git push origin main` into a terminal ("πŸ‘€ You: review the diff,
62
+ // commit and push"), and the card sat on Home under what only the user can do
63
+ // (paperclip-steve, 2026-10-08). Staging, committing, pulling and pushing in a local
64
+ // repo is an agent's job: it does the work, and asks only for an OK on the Workdesk
65
+ // before a push. A step that needs who the user is (a password, a token, signing in)
66
+ // is still theirs.
67
+ const GIT_WORK = /\bgit\s+(?:add|commit|push|pull|fetch|merge|rebase|stash|restore|checkout|switch)\b|\b(?:stage|commit|push|pull)(?:es|ed|ing)?\b(?:[^.;:]{0,60})\b(?:changes?|diff|fix|files?|branch|main|master|origin|upstream|remote|commits?|them|it)\b/i;
68
+ const IDENTITY = /\bpassword\b|\b2fa\b|\btwo-factor\b|\b(?:api )?token\b|\bsign(?:ing)? in\b|\blog ?in\b|\bssh key\b|\bpassphrase\b|\bsudo\b/i;
69
+ const gitWork = (text) => { const t = String(text || ""); return GIT_WORK.test(t) && !IDENTITY.test(t); };
70
+ const PUSH_OK = "asking for your OK on the Workdesk, with the files, before it pushes";
71
+ // A "πŸ‘€ You: …" option that's local git work, as the agent's: "πŸ€– Agent: … (asking for
72
+ // your OK … before it pushes)". Dropped instead when another option already has the
73
+ // agent do it. Options in, options out.
74
+ function agentsGitWork(options) {
75
+ const opts = (options || []).map(String), agentDoes = opts.some((o) => /πŸ€–/.test(o) && gitWork(o));
76
+ return opts.flatMap((o) => {
77
+ if (!/^\s*πŸ‘€/.test(o) || !gitWork(o)) return [o];
78
+ if (agentDoes) return [];
79
+ const rec = /\(recommended\)\s*$/i.test(o), body = o.replace(/^\s*πŸ‘€\s*You(?:\s*\([^)]*\))?\s*:?\s*/i, "").replace(/\s*\(recommended\)\s*$/i, "").replace(/[.\s]+$/, "");
80
+ return [`πŸ€– Agent: ${body}${/push/i.test(body) ? `, ${PUSH_OK}` : ""}${rec ? " (recommended)" : ""}`];
81
+ });
82
+ }
83
+
84
+ // ---- posting on a linked platform is an agent's job -------------------------------
85
+ // A Marketing card offered two options, both "πŸ‘€ You (only you: your linkedin): schedule
86
+ // all three now in linkedin's scheduler… paste each post's text and attach its card"
87
+ // (the [Steve] series, 2026-10-08), with LinkedIn linked in Symbiot's signed-in browser.
88
+ // Posting, scheduling, pasting or attaching there is the agent's work; the user's only
89
+ // step is approving the post's preview. Signing in (a platform not linked, or signed
90
+ // out) is still theirs. linked: the platforms linked in Symbiot, by id or name.
91
+ const PLATFORMS = { linkedin: /\blinked ?in\b/i, x: /\b(?:twitter|tweets?|x\.com)\b/i, facebook: /\bfacebook\b/i, instagram: /\binstagram\b/i, threads: /\bthreads\b/i, bluesky: /\bbluesky\b/i, mastodon: /\bmastodon\b/i, youtube: /\byoutube\b/i, tiktok: /\btiktok\b/i, reddit: /\breddit\b/i };
92
+ const POSTS_THERE = /\b(?:post|schedul|publish|paste|attach|upload|share|put (?:it|them|the posts?) up)\w*/i;
93
+ const platformOf = (text) => Object.keys(PLATFORMS).find((k) => PLATFORMS[k].test(String(text || ""))) || "";
94
+ function postsOnLinked(text, linked = []) {
95
+ const t = String(text || ""), on = platformOf(t);
96
+ return !!on && linked.map((x) => String(x).toLowerCase().replace(/[^a-z]/g, "")).includes(on) && POSTS_THERE.test(t) && !IDENTITY.test(t);
97
+ }
98
+ const APPROVE_FIRST = "once you approve its preview on the Workdesk";
99
+ // A "πŸ‘€ You: …" option that's posting on a linked platform, as the agent's: "πŸ€– Agent: …
100
+ // once you approve its preview". Options in, options out (like agentsGitWork).
101
+ function agentsPosting(options, linked = []) {
102
+ return (options || []).map(String).map((o) => {
103
+ if (!/^\s*πŸ‘€/.test(o)) return o;
104
+ const rec = /\(recommended\)\s*$/i.test(o), body = o.replace(/^\s*πŸ‘€\s*You(?:\s*\([^)]*\))?\s*:?\s*/i, "").replace(/\s*\(recommended\)\s*$/i, "").replace(/[.\s]+$/, "");
105
+ if (!postsOnLinked(o, linked)) return o; // the platform can be named only in its "(only you: your linkedin)"
106
+ return `πŸ€– Agent: ${body}, ${APPROVE_FIRST}${rec ? " (recommended)" : ""}`;
107
+ });
108
+ }
109
+
110
+ // ---- what's linked is never asked for again ----------------------------------------
111
+ // A Marketing card asked "let a poster on this computer put the steve posts up for
112
+ // you?" with two options, both "πŸ‘€ You (only you: your linkedin sign-in)": "ok it…" and
113
+ // "keep doing it by hand. schedule…" (2026-10-08), with LinkedIn already signed in in
114
+ // Symbiot's browser. On a platform that's linked, a πŸ‘€ sign-in, link, consent or
115
+ // developer-app step is the agent's (it uses that session), and doing it by hand is
116
+ // never offered. Signing in again stays the user's only when a real attempt found the
117
+ // session expired, and the option says so. Options in, options out; never none.
118
+ const ASKS_ACCESS = /\bsign(?:ing|ed)?[- ]?(?:in|up)\b|\blog(?:ging)?[- ]?in\b|\blink(?:ing)?\b|\bconnect(?:ing)?\b|\bconsent\b|\bauthori[sz]e\b|\bok it\b|\bdeveloper app\b|\bclient (?:id|secret)\b|\bapi (?:key|access)\b|\boauth\b/i;
119
+ const BY_HAND = /\bby hand\b|\bmanually\b|\byourself\b|\bon your own\b/i;
120
+ const EXPIRED = /\bexpired?\b|\bsigned out\b|\blogged out\b/i;
121
+ const LINKED_NAMES = { linkedin: "LinkedIn", x: "X", facebook: "Facebook", instagram: "Instagram", threads: "Threads", bluesky: "Bluesky", mastodon: "Mastodon", youtube: "YouTube", tiktok: "TikTok", reddit: "Reddit" };
122
+ const isLinked = (on, linked) => !!on && linked.map((x) => String(x).toLowerCase().replace(/[^a-z]/g, "")).includes(on);
123
+ const viaLinked = (on) => `πŸ€– Agent: post it through Symbiot's browser, already signed in to ${LINKED_NAMES[on] || on}, ${APPROVE_FIRST}`;
124
+ // about: the question and its context, for an option that doesn't name the platform.
125
+ function linkedAsks(options, linked = [], about = "") {
126
+ const opts = (options || []).map(String), q = platformOf(about); let on = "";
127
+ const out = opts.flatMap((o) => {
128
+ const p = platformOf(o) || q; if (!isLinked(p, linked) || EXPIRED.test(o)) return [o];
129
+ on = p;
130
+ if (BY_HAND.test(o)) return []; // the agent does it: "do it by hand" is never an option
131
+ if (!/^\s*πŸ‘€/.test(o) || !ASKS_ACCESS.test(o)) return [o];
132
+ return [viaLinked(p) + (/\(recommended\)\s*$/i.test(o) ? " (recommended)" : "")];
133
+ });
134
+ const seen = new Set(), kept = out.filter((o) => { const k = o.replace(/\s*\(recommended\)\s*$/i, ""); if (seen.has(k)) return false; seen.add(k); return true; });
135
+ return kept.length ? kept : on ? [viaLinked(on) + " (recommended)"] : opts;
136
+ }
137
+ // A "πŸ‘€ You" step a run's last words leave (leftToYou) that's signing in, linking or
138
+ // doing by hand what a linked platform's session already does: not the user's.
139
+ const linkedChore = (text, linked = []) => { const t = String(text || ""), p = platformOf(t); return isLinked(p, linked) && !EXPIRED.test(t) && (ASKS_ACCESS.test(t) || BY_HAND.test(t)); };
140
+
59
141
  // ---- what a run's last words leave to the user ----------------------------------
60
142
  // A run that ends saying something waits on the user ("the draft isn't sent",
61
143
  // "only you can do that", "needs sudo") without asking it in QUESTIONS.md left it
@@ -98,7 +180,7 @@ function leftToYou(text) {
98
180
  // "The draft isn't sent." says which draft only in its own point ("**Draft:** it's in Gmail, to …")
99
181
  const about = !label && SENDS.test(s) ? points.find((p) => p.label && SENDS.test(p.label) && p.ss.length) : null;
100
182
  const what = label ? `${label}: ${s}` : about ? `${s} ${about.label}: ${about.ss[0]}` : s;
101
- return { kind: HELD.test(s) || ACTS.test(s) ? "approve" : "step", what: clean(what, 240), check: clean(check === s ? "" : check, 240), label: clean(label || (about && about.label), 60) };
183
+ return { kind: HELD.test(s) || ACTS.test(s) || gitWork(s + " " + check) ? "approve" : "step", what: clean(what, 240), check: clean(check === s ? "" : check, 240), label: clean(label || (about && about.label), 60) };
102
184
  }
103
185
  return null;
104
186
  }
@@ -115,4 +197,4 @@ function parseHandoffs(md) {
115
197
  return out.map((h) => ({ lane: h.lane, text: h.text.trim().slice(0, HANDOVER_MAX) })).filter((h) => h.lane && h.text);
116
198
  }
117
199
 
118
- export { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs, leftToYou };
200
+ export { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, gitWork, agentsGitWork, PUSH_OK, postsOnLinked, agentsPosting, APPROVE_FIRST, linkedAsks, linkedChore, parseFacts, parseAwaiting, parseHandoffs, leftToYou };