symbiot 0.58.2 → 0.58.3

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,23 @@
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.58.3 — 2026-10-09
6
+
7
+ - Fixed urgent requests to a parked project: it is now unparked for the work, and other projects are released if nothing starts.
8
+ - Fixed a stuck urgent hold that kept projects paused for hours; it now clears about two minutes after nothing starts running.
9
+ - Changed agents so they no longer start in a folder with no open tasks or new answers, which avoids wasted sessions.
10
+ - Fixed Symbiot saying an agent was "already on urgent work" when nothing was running.
11
+ - Added a "posted" status for marketing drafts so a posted draft leaves the Marketing orb and can't be posted twice.
12
+ - Added `symbiot screens upload` to attach a picture or video to a page's file field, such as a LinkedIn post.
13
+ - Added a fact-only brand brief for the designer making the Symbiot icon and logo.
14
+ - Changed the LinkedIn reader to capture who sent each message and what each notification is about, not just badge counts.
15
+ - Added a review of the mobile companion spec, with follow-up tasks and open decisions.
16
+ - Added a "superseded" status so a redone marketing post replaces the old one on the Marketing orb.
17
+ - Added the pick tray to the Marketing page, showing captures with switchable blur boxes and one-click media selection.
18
+ - Fixed Home cards erasing what you were typing in "answer in your own words" whenever the page updated.
19
+ - Fixed tall question cards on Home overlapping the working-agents row below them.
20
+ - Changed where Symbiot keeps your AI key and app token so agent runs can no longer read them.
21
+
5
22
  ## 0.58.2
6
23
 
7
24
  - **A voice that doesn't sound robotic, picked for you.** When Symbiot reads a reply out, it uses your computer's own voice only when that's a modern, natural one (Edge's Natural voices on Windows, Apple's Premium and Enhanced voices, a phone's own). Otherwise, as on Linux, where Chrome only has its older Google voices and espeak, Symbiot speaks with its own voice: Piper, a small open-source speech engine that runs on your computer. The accent follows where you are (British English for South Africa, the UK, Australia and the rest; American for the US and Canada), and both voices are public domain. Nothing to choose, no account, nothing leaves your computer. It downloads once (about 85 MB) the first time it's needed, while the system voice covers that first reply, and then answers sentence by sentence, the first in under a second.
package/README.md CHANGED
@@ -1250,6 +1250,8 @@ symbiot screens type <id> "Search mail" "invoice" --enter --yes # type into a
1250
1250
  # (--yes isn't needed on a site under Trusted sites in Settings)
1251
1251
  symbiot screens type <id> To "sam@example.com" --yes # without --enter: the text stays in the field...
1252
1252
  symbiot screens press <new id> Send --yes # ...for a press on the screen that just printed
1253
+ symbiot screens upload <id> "Add media" ~/clip.mp4 --yes # put a file in the page's file box: the field,
1254
+ # or the button a site hides it behind ("used" says which box)
1253
1255
  symbiot screens scroll <id> # "more": "below"? scroll down and map the next part (or up, top, bottom)
1254
1256
  symbiot screens whole <id> # all of that page in one tall screen (or: symbiot screens map <site> --whole)
1255
1257
  symbiot screens show <id> # a saved screen's blueprint
package/agents.mjs CHANGED
@@ -190,7 +190,7 @@ function withTrust(tmpl, box = null) {
190
190
  // config.agentSandbox: false turns it off.
191
191
  const SANDBOX_DIR = join(CONFIG_DIR, "sandbox");
192
192
  const SANDBOX_CACHES = [".npm", ".cache"]; // npm's cache and logs; pip, uv, node-gyp, Chrome for Testing
193
- const SANDBOX_SECRETS = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", ".config/gcloud", ".config/gh", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/config.json"]; // your AI's key and the app's token too
193
+ const SANDBOX_SECRETS = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", ".config/gcloud", ".config/gh", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/config.json", ".config/symbiot/secrets.json"]; // your AI's key and the app's token too
194
194
  // Local sockets are allowed (headless Chrome needs one to start, so the browser tests
195
195
  // ran in no sandboxed run), but the ones that would be a way out are hidden: Docker's,
196
196
  // and your session's (its bus starts commands outside, through systemd-run; X11 and
@@ -264,12 +264,14 @@ function noteSession(path, code, resumed) {
264
264
  // A folder whose last run stopped on questions, with nothing changed since
265
265
  // that could answer them, gets no run: { blocked, questions, note }
266
266
  // (blockedAgain). force starts one anyway (something changed elsewhere).
267
- function runHandoff(repoPath, { force = false } = {}) {
267
+ // A folder with nothing to work from gets none either, forced or not (nothingToDo).
268
+ function runHandoff(repoPath, { force = false, lost = false } = {}) {
268
269
  let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
269
270
  if (isParked(repoPath)) return { blocked: true, parked: true, questions: 0, note: PARKED_NOTE }; // even forced: that's what parking is for
270
271
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
271
272
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
272
273
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
274
+ const idle = !lost && nothingToDo(repoPath); if (idle) return { blocked: true, idle: true, questions: 0, note: idle };
273
275
  const scoped = withScope(withConnectors(tmpl, repoPath), repoPath), box = isClaudeCmd(scoped) && trustFull() ? sandboxFor(repoPath, scoped) : null; // a repo run's sandbox, on top of the membrane
274
276
  tmpl = withStream(withTrust(scoped, box));
275
277
  const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null;
@@ -282,7 +284,7 @@ function runHandoff(repoPath, { force = false } = {}) {
282
284
  const own = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null, boxEnv = sandboxEnv(box);
283
285
  const env = own || boxEnv ? { ...(own || {}), ...(boxEnv || {}) } : null;
284
286
  const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(runCmd, repoPath, resume ? RESUME_PROMPT : HANDOFF_PROMPT), repoPath, (code) => {
285
- if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
287
+ if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
286
288
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
287
289
  noteBlocked(repoPath, tmpl, e.startedAt, code);
288
290
  setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
@@ -296,6 +298,27 @@ function runHandoff(repoPath, { force = false } = {}) {
296
298
  noteRun(e);
297
299
  return e;
298
300
  }
301
+ // ---- nothing to do: no run ----------------------------------------------------
302
+ // A run in ~ started with no .symbiot/TASKS.md there, found nothing to action and
303
+ // only cost a session (2026-10-07). A run needs something to work from: an
304
+ // unticked task, or an answer that came in since the folder's last run started
305
+ // (an approved post or draft, a reply to its question: those carry on with every
306
+ // task ticked, and Marketing's folder may have no TASKS.md at all). Else the note
307
+ // for { blocked, idle }, even forced: Start it anyway would start the same
308
+ // nothing. Briefs are written before their run starts (actNow, draftReply,
309
+ // pushTasks), and a held one lands first (runHandoff), so they count. Only the
310
+ // run that restarts a lost conversation skips this: its answer is older than the
311
+ // failed resume's start.
312
+ const OPEN_TASK = /^\s*(?:[-*+]|\d+[.)])\s*\[ \]/m;
313
+ function nothingToDo(path) {
314
+ const md = readSymbiot(path, "TASKS.md"); if (OPEN_TASK.test(md)) return "";
315
+ let answered = 0; try { answered = statSync(join(path, ".symbiot", "ANSWERS.md")).mtimeMs; } catch {}
316
+ const last = [...readSymbiot(path, "agent.log").matchAll(/^=== .* (\d{4}-\d\d-\d\dT[\d:.]+Z) ===$/gm)].pop();
317
+ if (answered && answered > (last ? Date.parse(last[1]) || 0 : 0)) return "";
318
+ const name = path === homedir() ? "~" : path.split(/[\\/]/).filter(Boolean).pop() || path;
319
+ return md.trim() ? `Not started: every task in ${name}'s .symbiot/TASKS.md is ticked and no answer has come in since its last run, so an agent there would have nothing to do. Send it a task first.`
320
+ : `Not started: ${name} has no .symbiot/TASKS.md, so an agent there would have nothing to do. Send it a task first.`;
321
+ }
299
322
  // ---- a parked lane: its tasks start no runs ----------------------------------
300
323
  // A lane blocked on something only the user can give (a token from Meta) ran 27
301
324
  // times: every answer, handover, Go and Start it anyway started it again, to say
@@ -321,7 +344,12 @@ function parkLane(path, on) {
321
344
  // test stopped halfway would be void), stops the urgent lane's own routine run (its
322
345
  // changes stay in the folder, and the next run carries on from them) so the urgent
323
346
  // 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.
347
+ // old order and starts the ones with work waiting. Lanes you parked stay parked,
348
+ // except the urgent lane itself: on 2026-10-09 symbiot was parked, so every urgent
349
+ // handover there parked marketing, started nothing, and still said "it's already on
350
+ // urgent work, and takes this next" while 0 agents worked. Asking for urgent work in
351
+ // a lane is the newest thing you've said about it, so it's unparked for it; and if
352
+ // its run still doesn't start, urgentUndo takes back what urgentFirst did.
325
353
  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
354
  function isUrgent(text) {
327
355
  const t = String(text || "");
@@ -338,24 +366,43 @@ function stopRun(path) {
338
366
  try { process.kill(-r.pid, "SIGTERM"); } catch { try { process.kill(r.pid, "SIGTERM"); } catch { return false; } } // its own process group (track: detached)
339
367
  return true;
340
368
  }
341
- // lanes: the paths to consider ({ name: path }); returns { parked: [names], stopped }
369
+ // lanes: the paths to consider ({ name: path }); returns { parked: [names], parkedPaths,
370
+ // stopped, unparked (it was parked: now it isn't), busy: "urgent" | "routine" | "" (a run
371
+ // there now: one already on urgent work, which this waits for, or one it couldn't stop) }
342
372
  function urgentFirst(path, { lanes = null, stop = stopRun, running = runningHandoff } = {}) {
343
- if (!path) return { parked: [], stopped: false };
373
+ if (!path) return { parked: [], parkedPaths: [], stopped: false, unparked: false, busy: "" };
344
374
  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
375
+ if (!u.paths.includes(path)) { u.paths.push(path); u.at = Date.now(); }
376
+ if (u.parked.includes(path)) u.parked = u.parked.filter((p) => p !== path); // urgent itself now
377
+ const unparked = isParked(path); if (unparked) parkLane(path, false);
347
378
  const all = lanes || Object.fromEntries(loadRuns().map((r) => [String(r.name || r.path.split("/").pop()), r.path]));
348
- const parked = [], already = parkedPaths();
379
+ const parked = [], mine = [], already = parkedPaths();
349
380
  for (const [name, p] of Object.entries(all)) {
350
381
  if (!p || u.paths.includes(p) || already.includes(p)) continue; // yours stay yours
351
382
  if (!running(p) && !readSymbiot(p, HELD)) continue; // nothing going or waiting there
352
- parkLane(p, true); u.parked.push(p); parked.push(name);
383
+ parkLane(p, true); u.parked.push(p); parked.push(name); mine.push(p);
353
384
  }
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")));
385
+ // its own lane: a routine run stops so this one starts now; one that's already on urgent
386
+ // work keeps going, and counts as the urgent run, so its end resumes what was parked
387
+ const busy = running(path), onIt = !!busy && (u.runs.includes(busy.id) || hasOpenUrgent(readSymbiot(path, "TASKS.md")));
388
+ if (onIt && busy.id && !u.runs.includes(busy.id)) u.runs.push(busy.id);
356
389
  const stopped = !!busy && !onIt && stop(path);
357
390
  saveUrgent(u);
358
- return { parked, stopped };
391
+ return { parked, parkedPaths: mine, stopped, unparked, busy: !busy ? "" : onIt ? "urgent" : stopped ? "" : "routine" };
392
+ }
393
+ // The urgent lane's run didn't start (no agent command, a step of yours first, its
394
+ // questions unanswered) and nothing runs there: nothing will end to resume the lanes
395
+ // urgentFirst parked, so they're unparked now, and the lane leaves the urgent record.
396
+ function urgentUndo(path, did, { running = runningHandoff } = {}) {
397
+ if (!path || !did || running(path)) return false;
398
+ const u = urgentState(); if (!u) return false;
399
+ const back = (did.parkedPaths || []).filter((p) => u.parked.includes(p));
400
+ for (const p of back) if (parkedPaths().includes(p)) parkLane(p, false);
401
+ u.parked = u.parked.filter((p) => !back.includes(p));
402
+ u.paths = u.paths.filter((p) => p !== path);
403
+ saveUrgent(u.paths.length ? u : null);
404
+ if (!u.paths.length && u.parked.length) resumeParked(u.parked); // parked by an earlier urgent ask that never ran either
405
+ return true;
359
406
  }
360
407
  // the urgent lane's run started: when it ends, the parked work resumes (runHandoff's exit)
361
408
  function noteUrgentRun(path, id) { const u = urgentState(); if (u && u.paths.includes(path) && id && !u.runs.includes(id)) { u.runs.push(id); saveUrgent(u); } }
@@ -376,11 +423,13 @@ function resumeParked(paths, start = runHandoff) {
376
423
  return { unparked: paths, resumed };
377
424
  }
378
425
  // 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;
426
+ // the parked lanes don't wait forever. A record with no run going in any urgent lane is
427
+ // over once URGENT_GRACE has passed (a stopped routine run takes a moment to exit, and
428
+ // the urgent one starts then): before, one whose run never started held marketing for 6h.
429
+ const URGENT_MAX = 6 * 3600 * 1000, URGENT_GRACE = 2 * 60 * 1000;
381
430
  function urgentSweep({ running = runningHandoff, now = Date.now(), start = runHandoff } = {}) {
382
431
  const u = urgentState(); if (!u) return null;
383
- const ended = u.runs.length > 0 && !u.paths.some((p) => running(p));
432
+ const ended = !u.paths.some((p) => running(p)) && (u.runs.length > 0 || now - (u.at || 0) >= URGENT_GRACE);
384
433
  if (!ended && now - (u.at || 0) < URGENT_MAX) return null;
385
434
  saveUrgent(null);
386
435
  return resumeParked(u.parked, start);
@@ -999,7 +1048,7 @@ const HARM_RULE = /^Bash\(\s*(rm|dd|mkfs|chmod|chown|shred|truncate)\b/i;
999
1048
  const wideRule = (r) => ANY_RULE.test(r) || (HARM_RULE.test(r) && /\*/.test(r));
1000
1049
  // Whatever an agent is allowed, it never edits Symbiot's own settings: that's where
1001
1050
  // what agents may do is kept, so an agent could widen its own permissions there.
1002
- const GUARD = () => { const c = join(homedir(), ".config", "symbiot", "config.json"); return [`Edit(${c})`, `Write(${c})`]; };
1051
+ const GUARD = () => { const c = join(homedir(), ".config", "symbiot", "config.json"), s = join(homedir(), ".config", "symbiot", "secrets.json"); return [`Edit(${c})`, `Write(${c})`, `Read(${s})`, `Edit(${s})`, `Write(${s})`]; };
1003
1052
  function installAllowlist(path) {
1004
1053
  let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return null; }
1005
1054
  const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
@@ -1190,4 +1239,4 @@ function agentsList() {
1190
1239
  }).concat(earlierRuns());
1191
1240
  }
1192
1241
 
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 };
1242
+ 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, nothingToDo, 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, urgentUndo, urgentDone, urgentSweep, urgentState };
package/core.mjs CHANGED
@@ -21,17 +21,43 @@ async function checkLatest() { try { const r = await fetch(REGISTRY + "/symbiot"
21
21
  // ---- config ---------------------------------------------------------------
22
22
  const CONFIG_DIR = join(homedir(), ".config", "symbiot");
23
23
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
24
+ // Symbiot's own keys (your AI's API key, the app's token) live apart from its
25
+ // settings, in secrets.json: a run outside the sandbox could `cat` config.json, and
26
+ // with the token call Symbiot's own API and change the agent command, which the
27
+ // membrane never sees. The membrane (guard.mjs) and a run's sandbox (agents.mjs
28
+ // SANDBOX_SECRETS) refuse secrets.json. loadConfig joins the two and saveConfig
29
+ // splits them, so nothing else changes; a config.json from before still reads, and
30
+ // its keys move out on the next save (the app saves once as it starts: moveSecrets).
31
+ const SECRETS_PATH = join(CONFIG_DIR, "secrets.json"), SECRET_KEYS = ["apiKey", "appToken"];
32
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
33
+ function splitSecrets(cfg) {
34
+ const pub = { ...cfg }, sec = {};
35
+ for (const k of SECRET_KEYS) if (k in pub) { sec[k] = pub[k]; delete pub[k]; }
36
+ for (const [k, v] of Object.entries(pub)) if (isObj(v) && "apiKey" in v) { sec[k] = { apiKey: v.apiKey }; pub[k] = { ...v }; delete pub[k].apiKey; } // a provider's: { anthropic: { apiKey, model } }
37
+ return { pub, sec };
38
+ }
39
+ const readJson = (f) => { try { const o = JSON.parse(readFileSync(f, "utf8")); return isObj(o) ? o : {}; } catch { return {}; } };
24
40
  function loadConfig() {
25
- try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
41
+ const cfg = readJson(CONFIG_PATH), sec = readJson(SECRETS_PATH);
42
+ for (const [k, v] of Object.entries(sec)) {
43
+ if (SECRET_KEYS.includes(k)) { if (!(k in cfg)) cfg[k] = v; } // one still in config.json is newer (an older Symbiot wrote it)
44
+ else if (isObj(v) && "apiKey" in v && !(isObj(cfg[k]) && "apiKey" in cfg[k])) cfg[k] = { ...(isObj(cfg[k]) ? cfg[k] : {}), apiKey: v.apiKey };
45
+ }
46
+ return cfg;
26
47
  }
27
48
  function saveConfig(cfg) {
28
49
  try {
29
50
  mkdirSync(CONFIG_DIR, { recursive: true });
30
- writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
51
+ const { pub, sec } = splitSecrets(cfg || {});
52
+ writeFileSync(SECRETS_PATH, JSON.stringify(sec, null, 2) + "\n", { mode: 0o600 }); // first: a key is never only in neither
53
+ try { chmodSync(SECRETS_PATH, 0o600); } catch {}
54
+ writeFileSync(CONFIG_PATH, JSON.stringify(pub, null, 2) + "\n");
31
55
  try { chmodSync(CONFIG_PATH, 0o600); } catch {}
32
56
  return true;
33
57
  } catch { return false; }
34
58
  }
59
+ // Keys still in config.json (from before secrets.json): moved out. True if any were.
60
+ function moveSecrets() { const raw = readJson(CONFIG_PATH); return Object.keys(splitSecrets(raw).sec).length ? saveConfig(loadConfig()) : false; }
35
61
  // ---- tasks: a persistent checklist (~/.config/symbiot/tasks.json) ---------
36
62
  const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
37
63
  function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
@@ -180,4 +206,4 @@ function repoState(repoPath) {
180
206
  return { branch, dirty, del, mod, add, stale, staleBy, behind };
181
207
  }
182
208
 
183
- export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
209
+ export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, SECRETS_PATH, loadConfig, saveConfig, moveSecrets, splitSecrets, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
package/guard.mjs CHANGED
@@ -7,7 +7,8 @@
7
7
  // - push to main/master, or force-push; publish a package
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
- // - read SSH keys or cloud credentials; change Symbiot's own settings
10
+ // - read SSH keys or cloud credentials, or Symbiot's own keys (secrets.json: your AI's
11
+ // key and the app's token); change Symbiot's own settings
11
12
  // - open the user's running Symbiot in a browser (a check uses a sandbox copy)
12
13
  // - in a sandboxed repo run (agents.mjs sandboxFor), edit or write a file outside its
13
14
  // repo and your folders (SYMBIOT_WRITES): its commands are held there by the sandbox
@@ -18,7 +19,7 @@ import { execFileSync } from "node:child_process";
18
19
  import { readFileSync } from "node:fs";
19
20
  import { fileURLToPath } from "node:url";
20
21
 
21
- const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials"];
22
+ const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/secrets.json"];
22
23
  const under = (p, d) => p === d || p.startsWith(d.endsWith("/") ? d : d + "/");
23
24
  const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, home).replace(/^\$HOME(?=\/|$)/, home).replace(/^\$\{HOME\}(?=\/|$)/, home));
24
25
  const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
@@ -65,7 +66,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
65
66
  const own = w[0] === "scp" ? SCP_VALUE : null;
66
67
  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" }; }
67
68
  }
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" };
69
+ if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/(config|secrets)\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
69
70
  }
70
71
  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
72
  // a browser on the user's running Symbiot: each page it opened counted as a new window and
package/headless.mjs CHANGED
@@ -2,11 +2,12 @@
2
2
  // screenshot and map every button, link and field on it by itself, as a screen
3
3
  // whose regions are already named (screens.mjs). Nothing to bring to the front,
4
4
  // nothing to drag. Each region keeps how to find it again (a CSS selector), so it
5
- // can be pressed (or a field typed into) in that same hidden browser, which maps
6
- // the page it lands on: map → press → map is how an agent finds its way around a
7
- // site. On a site you trust (Settings), that goes ahead without asking. In the
8
- // app the browser stays open for a few minutes after each action, so the next
9
- // one carries on from the page as it is (type into a field, then press Send).
5
+ // can be pressed (a field typed into, a file put in a file box) in that same
6
+ // hidden browser, which maps the page it lands on: map → press → map is how an
7
+ // agent finds its way around a site. On a site you trust (Settings), that goes
8
+ // ahead without asking. In the app the browser stays open for a few minutes
9
+ // after each action, so the next one carries on from the page as it is (type
10
+ // into a field, then press Send).
10
11
  // A map has what fits in the window: Scroll down maps the next part, and Whole
11
12
  // page maps all of it in one tall screenshot: the page, or, where a list scrolls
12
13
  // inside it (Gmail's mail), that list opened out to its full length.
@@ -19,8 +20,8 @@
19
20
  // site once there (signIn opens it as a normal window) and later maps see it
20
21
  // signed in. It's separate from your everyday browser profile.
21
22
  import { spawn } from "node:child_process";
22
- import { join } from "node:path";
23
- import { mkdirSync, existsSync } from "node:fs";
23
+ import { join, resolve as resolvePath } from "node:path";
24
+ import { mkdirSync, existsSync, statSync } from "node:fs";
24
25
  import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
25
26
  import { loadScreens, addPageScreen, center, stitchPng, stitchListPng } from "./screens.mjs";
26
27
 
@@ -499,6 +500,39 @@ function readTexts(input, selector, { scrolls = 3 } = {}) {
499
500
  }, false)).catch((e) => ({ error: String((e && e.message) || e) }));
500
501
  }
501
502
 
503
+ // LinkedIn's own lists, for Watch (watch.mjs linkedinDetail): its top bar's badges
504
+ // say "Messaging, 1 new notification", never who, and the brief could only say
505
+ // "2 new notifications" (2026-10-09). This reads who and what, in the signed-in
506
+ // hidden browser, only looking, like readPage: "messages" (each conversation: who,
507
+ // its last line, when, unread) or "notifications" (each card: its words, who, its
508
+ // link, unread). LinkedIn's class names drift, so each has a fallback: its heading,
509
+ // its first paragraph. { items } or { busy } / { error }.
510
+ const LI_TEXT = "const tx = (r, s) => { const e = r.querySelector(s); return e ? String(e.innerText || e.textContent || '').replace(/\\s+/g, ' ').trim() : ''; };";
511
+ const LI_UNREAD = "const un = (r) => /unread/i.test(String(r.className || '')) || !!r.querySelector('[class*=unread],.notification-badge--show') || /\\bunread\\b/i.test(r.getAttribute('aria-label') || '');";
512
+ const LI_READ = {
513
+ messages: ["https://www.linkedin.com/messaging/", `(() => { ${LI_TEXT} ${LI_UNREAD}
514
+ return [...document.querySelectorAll('li.msg-conversation-listitem, li.msg-conversations-container__convo-item')].slice(0, 20).map((r) => ({
515
+ name: tx(r, '.msg-conversation-listitem__participant-names, .msg-conversation-card__participant-names, h3'),
516
+ preview: tx(r, '.msg-conversation-card__message-snippet, .msg-conversation-card__message-snippet-body, p').slice(0, 200),
517
+ time: tx(r, 'time, .msg-conversation-listitem__time-stamp, .msg-conversation-card__time-stamp'), unread: un(r) })).filter((x) => x.name);
518
+ })()`],
519
+ notifications: ["https://www.linkedin.com/notifications/", `(() => { ${LI_TEXT} ${LI_UNREAD}
520
+ return [...document.querySelectorAll('article.nt-card, .nt-card-list article, main article')].slice(0, 20).map((r) => ({
521
+ text: (tx(r, '.nt-card__text--3-line, .nt-card__headline, .nt-card__text') || String(r.innerText || '').replace(/\\s+/g, ' ').trim()).slice(0, 300),
522
+ actor: tx(r, '.nt-card__text--3-line strong, .nt-card__headline strong, strong').slice(0, 80),
523
+ href: String((r.querySelector('a[href*="linkedin.com/"], a[href^="/"]') || {}).href || '').slice(0, 500), unread: un(r) })).filter((x) => x.text);
524
+ })()`],
525
+ };
526
+ function readLinkedIn(kind) {
527
+ const it = LI_READ[kind]; if (!it) return Promise.resolve({ error: "messages or notifications" });
528
+ return oneAtATime(() => browserOpen() ? { busy: true } : withPage(async (page) => {
529
+ await open(page, it[0]);
530
+ const at = String((await evaluate(page, "location.href")) || it[0]);
531
+ if (signInPage(at)) return { login: true, url: at };
532
+ return { url: at, items: (await evaluate(page, it[1])) || [] };
533
+ }, false)).catch((e) => ({ error: String((e && e.message) || e) }));
534
+ }
535
+
502
536
  // A picture, or a short clip, of a page, for a post (post.mjs): only on your
503
537
  // click, and it only looks, like readPage. The picture is the window at twice its
504
538
  // pixels, so it stays sharp in a feed: { png, url, title }. The clip records the
@@ -630,7 +664,7 @@ function actOnRegion(id, regionId, verb, confirmed, check, act) {
630
664
  const r = (s.regions || []).find((x) => x.id === regionId); if (!r) return Promise.resolve({ error: "That region is gone. Reload the screen." });
631
665
  const bad = check(r); if (bad) return Promise.resolve({ error: bad });
632
666
  const host = hostOf(s.page.url);
633
- if (!confirmed && !isTrusted(s.page.url)) return Promise.resolve({ error: `${verb === "Type" ? "Typing into" : "Pressing"} "${r.label}" acts on the real site, signed in as you, and ${host} isn't one of your trusted sites.`, confirm: true, host });
667
+ if (!confirmed && !isTrusted(s.page.url)) return Promise.resolve({ error: `${verb === "Type" ? "Typing into" : verb === "Upload" ? "Attaching a file with" : "Pressing"} "${r.label}" acts on the real site, signed in as you, and ${host} isn't one of your trusted sites.`, confirm: true, host });
634
668
  return oneAtATime(() => withPage(async (page, b) => {
635
669
  const here = await showScreen(page, b, s);
636
670
  // A new tab would leave this one where it was, so a link opens here instead.
@@ -701,6 +735,68 @@ function typeRegion(id, regionId, text, { enter = false, confirmed = false, noSe
701
735
  });
702
736
  }
703
737
 
738
+ // Upload: put a file (or a few) in a page's file box, as picking it in the file
739
+ // dialog would (DevTools' DOM.setFileInputFiles): a picture or video for a LinkedIn
740
+ // post. Sites hide the real <input type=file> behind a button ("Add media"), and a
741
+ // map only has what shows, so the field is usually that button: the box is then
742
+ // the one inside it, its label's, else the nearest one going up from it, and
743
+ // `used` says which. A page that makes its box only once the button is pressed:
744
+ // pressed, with the file dialog caught instead of shown, and the file put in the
745
+ // box that dialog was for. The files are checked (absolute, there) before a
746
+ // browser starts, and off your trusted sites it asks first, like press and type.
747
+ const FILE_BOX = (sel, at) => `(() => {
748
+ const isBox = (x) => !!x && x.tagName === 'INPUT' && String(x.type).toLowerCase() === 'file', all = [...document.querySelectorAll('input[type=file]')];
749
+ let e = null; try { e = ${JSON.stringify(sel || "")} && document.querySelector(${JSON.stringify(sel || "")}); } catch (x) {}
750
+ e = e || document.elementFromPoint(${Number(at.x) || 0}, ${Number(at.y) || 0});
751
+ let box = null, how = '';
752
+ if (isBox(e)) { box = e; how = 'the field itself'; }
753
+ else if (e) {
754
+ const lab = e.closest('label'), inside = e.querySelector('input[type=file]');
755
+ if (inside) { box = inside; how = 'the file box inside it'; }
756
+ else if (lab && isBox(lab.control)) { box = lab.control; how = "its label's file box"; }
757
+ else for (let n = e.parentElement; n && !box; n = n.parentElement) { const f = n.querySelector('input[type=file]'); if (f) { box = f; how = all.length === 1 ? 'the only file box on the page' : "the nearest of the page's " + all.length + ' file boxes'; } }
758
+ }
759
+ if (!box && all.length === 1) { box = all[0]; how = 'the only file box on the page'; }
760
+ if (!box) return null;
761
+ window.__symbiotFileBox = box;
762
+ const name = box.getAttribute('aria-label') || box.name || box.id || '';
763
+ return { how, box: 'input[type=file]' + (name ? ' "' + name + '"' : '') + (box.accept ? ' (takes ' + box.accept + ')' : ''), multiple: !!box.multiple };
764
+ })()`;
765
+ async function attachFiles(page, at, r, files) {
766
+ const box = await evaluate(page, FILE_BOX(r.selector, at));
767
+ if (box) {
768
+ if (files.length > 1 && !box.multiple) throw new Error(`${box.box} takes one file at a time.`);
769
+ const { result } = await page.send("Runtime.evaluate", { expression: "window.__symbiotFileBox" });
770
+ if (!result || !result.objectId) throw new Error("Lost the page's file box. Map the page again.");
771
+ await page.send("DOM.setFileInputFiles", { files, objectId: result.objectId });
772
+ await page.send("Runtime.evaluate", { expression: "delete window.__symbiotFileBox" }).catch(() => {});
773
+ return { used: `${box.box}, ${box.how}` };
774
+ }
775
+ // no box on the page yet: press it, and catch the file dialog that opens
776
+ await page.send("Page.setInterceptFileChooserDialog", { enabled: true });
777
+ try {
778
+ const opened = page.until("Page.fileChooserOpened", 5000);
779
+ await clickAt(page, at);
780
+ const m = await opened, p = (m && m.params) || {};
781
+ if (!p.backendNodeId) throw new Error(`No file box on the page, and pressing "${r.label}" didn't open a file dialog. Press it, then upload with what the new map shows.`);
782
+ if (files.length > 1 && p.mode !== "selectMultiple") throw new Error("That file box takes one file at a time.");
783
+ await page.send("DOM.setFileInputFiles", { files, backendNodeId: p.backendNodeId });
784
+ return { used: "the file box the file dialog it opened was for" };
785
+ } finally { await page.send("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {}); }
786
+ }
787
+ // files: a path or a list of them, relative to where you are
788
+ function uploadFiles(files) {
789
+ const list = (Array.isArray(files) ? files : [files]).filter((f) => f != null && String(f).trim()).map((f) => resolvePath(String(f)));
790
+ if (!list.length) return { error: "Give the file to upload." };
791
+ const bad = list.find((f) => { try { return !statSync(f).isFile(); } catch { return true; } });
792
+ return bad ? { error: `No file at ${bad}.` } : { files: list };
793
+ }
794
+ function uploadRegion(id, regionId, files, { confirmed = false } = {}) {
795
+ const f = uploadFiles(files);
796
+ return actOnRegion(id, regionId, "Upload", confirmed, () => f.error || "",
797
+ async (page, at, r) => ({ uploaded: r.label, files: f.files, ...(await attachFiles(page, at, r, f.files)) }));
798
+ }
799
+
704
800
  // Open the site in Symbiot's browser profile as a normal window, to sign in (or
705
801
  // accept cookies) once. Close it before mapping: the profile is shared. The hidden
706
802
  // browser is closed first, or the window would open in it, out of sight.
@@ -714,4 +810,4 @@ async function signIn(input) {
714
810
  return { ok: true, url };
715
811
  }
716
812
 
717
- export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
813
+ export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, readLinkedIn, LI_READ, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, uploadRegion, uploadFiles, attachFiles, FILE_BOX, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
package/home.mjs CHANGED
@@ -28,7 +28,7 @@ import { reportsNews, listReports, readReport, decide } from "./reports.mjs";
28
28
  import { awaitingState, inboxSight } from "./handback.mjs";
29
29
  import { newClashes } from "./checks.mjs";
30
30
  import { postsState } from "./post.mjs";
31
- import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles, setDraftStatus } from "./marketing.mjs";
31
+ import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles, setDraftStatus, postedCmd } from "./marketing.mjs";
32
32
 
33
33
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
34
34
  let cached = null;
@@ -223,7 +223,9 @@ function goLane(repo, map, { push = pushTasks, run = runHandoff } = {}) {
223
223
  if (!map[repo]) return { error: `${repo} isn't one of your projects any more.` };
224
224
  const p = (push || pushTasks)({ repo }); if (!p || !p.written || !p.written.length) return { error: `Nothing of ${repo}'s is waiting to start.` };
225
225
  const e = (run || runHandoff)(map[repo]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
226
- return e.parked ? { error: `${repo} is parked: unpark it to start its agent.` } : { ok: true, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }) };
226
+ if (e.parked) return { error: `${repo} is parked: unpark it to start its agent.` };
227
+ if (e.blocked && !e.busy) return { error: `${repo}'s agent didn't start: ${e.note || "it's waiting on something"}` }; // queued only behind a run that's really going
228
+ return { ok: true, ...(e.busy ? { queued: true } : { job: e.id }) };
227
229
  }
228
230
 
229
231
  // ---- Marketing: a lane of its own, across products (marketing.mjs) ----------------------
@@ -280,7 +282,7 @@ function marketingDraftAnswer(rel, status, deps = {}) {
280
282
  cached = null;
281
283
  const d = r.draft, when = d.when ? `, scheduled in ${d.platform}'s own scheduler for ${d.when}` : "";
282
284
  if (status === "skipped") { noteAnswer(dir, `Skipped: ${rel}`, "Don't post it. Leave the draft where it is."); return { ok: true, said: "Skipped. Its agent won't post it." }; }
283
- noteAnswer(dir, `Approved: ${rel}`, `The user approved its preview. Post it on ${d.platform}${when}, through Symbiot's signed-in browser, with exactly the text under its post (${d.body.length} characters, unchanged)${d.media.length ? ` and ${d.media.join(", ")} attached` : ""}. Then check it's there, and say so in your last message.`);
285
+ noteAnswer(dir, `Approved: ${rel}`, `The user approved its preview. Post it on ${d.platform}${when}, through Symbiot's signed-in browser, with exactly the text under its post (${d.body.length} characters, unchanged)${d.media.length ? ` and ${d.media.join(", ")} attached` : ""}. Then check it's there, mark it posted (\`${postedCmd(rel)}\`), and say so in your last message.`);
284
286
  if ((deps.running || runningHandoff)(dir)) return { ok: true, said: "Approved. Its agent posts it once the run there now finishes." };
285
287
  const e = (deps.run || runHandoff)(dir, { force: true });
286
288
  return e && e.id && !e.blocked ? { ok: true, said: `Approved. Its agent is ${d.when ? "scheduling" : "posting"} it now.`, rerun: e.id } : { ok: true, said: `Approved. ${(e && e.note) || "Start its agent to post it."}` };
@@ -491,12 +493,12 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
491
493
  const h = state || homeState({ now });
492
494
  let map = {}; try { map = laneMap(); } catch {}
493
495
  const role = "Here they're on Symbiot's home: one liquid surface that shows what only they can do, what's new on what they watch, and the lanes. Answer from what it shows, and act on what they ask.";
494
- const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data, path }) => ({ mime, data, path })), ...(ask ? { ask } : {}),
496
+ const r = await converse({ where: "Home", role, nav: true, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data, path }) => ({ mime, data, path })), ...(ask ? { ask } : {}),
495
497
  act: {
496
498
  agent: (req, known, repo) => (repo ? actIn(req + shotNote, repo, { map, known, title: "Home" }) : actNow(req + shotNote, { title: "Home", known })),
497
499
  task: (text, repo) => taskIn(text + shotNote, repo, { map }),
498
500
  } });
499
- return { answer: r.reply, ...(r.did ? { did: r.did } : {}), steps: r.steps || [] };
501
+ return { answer: r.reply, spoken: r.spoken, ...(r.did ? { did: r.did } : {}), ...(r.go ? { go: r.go } : {}), steps: r.steps || [] };
500
502
  }
501
503
 
502
504
  // ---- acting on a report, where you read it (reports.mjs reportIdeas) -------------------
@@ -527,7 +529,7 @@ async function reportAsk(id, question, { ask, deps = {} } = {}) {
527
529
  agent: (req, known, repo) => ((repo || lane) ? actIn(req, repo || lane, { map, known, title: "Reports" }) : actNow(req, { title: "Reports", known })),
528
530
  task: (text, repo) => taskIn(text, repo || lane, { map }),
529
531
  } });
530
- return { answer: res.reply, ...(res.did ? { did: res.did } : {}), steps: res.steps || [] };
532
+ return { answer: res.reply, spoken: res.spoken, ...(res.did ? { did: res.did } : {}), steps: res.steps || [] };
531
533
  }
532
534
  // Approve or Reject on a draft report: its agent is told in ANSWERS.md, and an approved
533
535
  // one goes ahead (its agent starts, or takes it once the run there finishes).