symbiot 0.57.4 → 0.57.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +41 -9
- package/agents.mjs +116 -8
- package/guard.mjs +12 -1
- package/handover.mjs +86 -4
- package/home.mjs +226 -25
- package/index.mjs +7 -11
- package/lanes.mjs +49 -6
- package/marketing.mjs +110 -8
- package/mind.mjs +7 -3
- package/package.json +2 -2
- package/post.mjs +41 -17
- package/reports.mjs +102 -20
- package/server.mjs +52 -16
- package/tasks.mjs +97 -30
- package/ui.mjs +107 -39
- package/watch.mjs +121 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
|
|
4
4
|
|
|
5
|
+
## 0.57.8
|
|
6
|
+
|
|
7
|
+
- **An approved deploy with your server key goes through.** The membrane read `scp -i ~/.ssh/oracle_key …` as copying your key, when the key is only what scp signs in with (`ssh -i` already passed). So argena's deploy to the game server stayed blocked even after you approved it twice. Now scp's sign-in options don't count as files it copies. Copying a key or credentials themselves (`scp ~/.ssh/id_rsa host:`) is still stopped.
|
|
8
|
+
- **Agents check Symbiot in a copy of their own.** Every brief now says to see Symbiot working in a sandbox (`symbiot app --fresh`), and the membrane stops a browser or screenshot pointed at your running app. That's what closed your window five times on 2026-10-08. Reading its API with curl is still fine.
|
|
9
|
+
|
|
10
|
+
## 0.57.7
|
|
11
|
+
|
|
12
|
+
- **A reply you sent yourself leaves Home.** If you paste a drafted reply in and send it yourself, then say so, its card goes, and so does a draft its agent noted as sent. Before, 0.57.6 kept asking for your OK on Frikkie's reply, already sent at 14:10, and showed the agent's note "Sent by the user… shortened to…" as if it were part of the reply.
|
|
13
|
+
- **One Symbiot, even mid-update.** A launch that found Symbiot still restarting after an update used to start a second copy on another port, with its own window, and every timer then ran twice. Now it waits for the restarted app, opens that window and leaves it at that.
|
|
14
|
+
|
|
15
|
+
## 0.57.6
|
|
16
|
+
|
|
17
|
+
- **See a drafted reply before you OK it.** A card like "Draft: Messaging, 1 new notification waits for your OK" used to say "The reply to Frikkie is ready for you to send" without showing the reply, so to check it you had to open LinkedIn. Now the card is "Reply to Frikkie on LinkedIn", with the reply itself on it, as it will look there (a LinkedIn message, a WhatsApp bubble or an email) and with none of the agent's notes. **Go ahead** sends it from your account in Symbiot's signed-in browser, exactly as shown. **Skip** drops it. **Change it** takes what you'd change in your own words: the agent redrafts, and the new version shows on the same card. The card no longer says the agent can't send it.
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
- **"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.
|
|
27
|
+
- **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`.
|
|
28
|
+
|
|
29
|
+
## 0.57.5
|
|
30
|
+
|
|
31
|
+
- **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".
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
5
34
|
## 0.57.4
|
|
6
35
|
|
|
7
36
|
- **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 +53,7 @@ What each Symbiot release brought, newest first. When Approve bumps the version,
|
|
|
24
53
|
- **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
54
|
- **`symbiot --version` and `symbiot --help` work.** Both ran the weekly write-up instead.
|
|
26
55
|
|
|
56
|
+
|
|
27
57
|
## 0.57.0
|
|
28
58
|
|
|
29
59
|
- **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
|
|
787
|
-
|
|
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> #
|
|
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
|
-
- **
|
|
892
|
-
|
|
893
|
-
|
|
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,
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/guard.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// - delete outside the agent's own folder, or wipe a disk
|
|
9
9
|
// - sudo, or pipe something from the internet into a shell
|
|
10
10
|
// - read SSH keys or cloud credentials; change Symbiot's own settings
|
|
11
|
+
// - open the user's running Symbiot in a browser (a check uses a sandbox copy)
|
|
11
12
|
// - in a sandboxed repo run (agents.mjs sandboxFor), edit or write a file outside its
|
|
12
13
|
// repo and your folders (SYMBIOT_WRITES): its commands are held there by the sandbox
|
|
13
14
|
// judge(tool, input, { cwd, home }) → null (go ahead) or { why }.
|
|
@@ -20,6 +21,8 @@ import { fileURLToPath } from "node:url";
|
|
|
20
21
|
const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials"];
|
|
21
22
|
const under = (p, d) => p === d || p.startsWith(d.endsWith("/") ? d : d + "/");
|
|
22
23
|
const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, home).replace(/^\$HOME(?=\/|$)/, home).replace(/^\$\{HOME\}(?=\/|$)/, home));
|
|
24
|
+
const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
|
|
25
|
+
const SCP_VALUE = /^-[346ABCOpqRrTv]*[iFoPSJcl]$/; // an scp option (alone or after flags, -rpi) whose value is the next word
|
|
23
26
|
function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
|
|
24
27
|
function symbiotConfig(p, home) { return p === join(home, ".config", "symbiot", "config.json"); }
|
|
25
28
|
|
|
@@ -55,11 +58,19 @@ function judgeBash(cmd, { cwd, home, branch }) {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
if (/^(cat|less|more|head|tail|cp|scp|base64|xxd|strings|grep|rg|sed|awk)$/.test(w[0])) {
|
|
58
|
-
|
|
61
|
+
// scp's sign-in options take a value (`-i ~/.ssh/key` is the key it signs in with, as
|
|
62
|
+
// ssh's is, never a file it copies): those aren't what it reads. Only what it copies
|
|
63
|
+
// counts: `scp ~/.ssh/id_rsa host:` stays blocked. The argena lane's approved deploy
|
|
64
|
+
// (`scp -i ~/.ssh/oracle_key server/x.py ubuntu@host:…`) was blocked (2026-10-08).
|
|
65
|
+
const own = w[0] === "scp" ? SCP_VALUE : null;
|
|
66
|
+
for (const p of w.slice(1).filter((a, i, all) => !a.startsWith("-") && !(own && own.test(all[i - 1] || "")))) { if (secretPath(expand(p, home, cwd), home)) return { why: "your keys and cloud credentials stay yours" }; }
|
|
59
67
|
}
|
|
60
68
|
if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/config\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
|
|
61
69
|
}
|
|
62
70
|
if (/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(c)) return { why: "running a script straight from the internet" };
|
|
71
|
+
// a browser on the user's running Symbiot: each page it opened counted as a new window and
|
|
72
|
+
// closed theirs, five times in four minutes (2026-10-08). Its API (curl) is fine to read.
|
|
73
|
+
if (BROWSER.test(c) && /\b(127\.0\.0\.1|localhost):7391\b/.test(c)) return { why: "that's the user's running Symbiot (their window and data): check it in a copy of your own, `symbiot app --fresh`" };
|
|
63
74
|
return null;
|
|
64
75
|
}
|
|
65
76
|
|
package/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
|
|
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 };
|