symbiot 0.58.2 → 0.59.0

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,29 @@
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.59.0
6
+
7
+ - **Symbiot Free and Symbiot Pro.** Every install starts with 14 days of Pro. After that, Free keeps the orb, Home, Talk to Symbiot, your week and standups, voice and the Away screen, with one agent at a time, up to 3 projects and one connected inbox or chat. Pro (US$12 a month or US$99 a year) adds any number of agents at once, every project, every inbox and chat, the marketing lane and Approve-and-ship. **Settings → Symbiot Pro** shows where you stand, takes a key and lets you swap one of Free's 3 projects. A limit never stops anything running: the next thing just doesn't start, with a line saying why. Keys are checked offline (signed by Ghost AI; cancelled keys are listed on symbiot.co.za).
8
+ - **Symbiot doesn't work on its own code.** No agent runs in Symbiot's repo or a fork of it, and the guard stops agents editing Symbiot's installed files or its licence files.
9
+ - **Elastic License 2.0** from this version on (0.58.2 and earlier stay MIT).
10
+ - **Tests never sign in or install for real.** The test that presses every button was starting a real Claude sign-in in the browser and running Anthropic's installer; both are now switched off in tests (`SYMBIOT_NO_SIGNIN`).
11
+ ## 0.58.3 — 2026-10-09
12
+
13
+ - Fixed urgent requests to a parked project: it is now unparked for the work, and other projects are released if nothing starts.
14
+ - Fixed a stuck urgent hold that kept projects paused for hours; it now clears about two minutes after nothing starts running.
15
+ - Changed agents so they no longer start in a folder with no open tasks or new answers, which avoids wasted sessions.
16
+ - Fixed Symbiot saying an agent was "already on urgent work" when nothing was running.
17
+ - Added a "posted" status for marketing drafts so a posted draft leaves the Marketing orb and can't be posted twice.
18
+ - Added `symbiot screens upload` to attach a picture or video to a page's file field, such as a LinkedIn post.
19
+ - Added a fact-only brand brief for the designer making the Symbiot icon and logo.
20
+ - Changed the LinkedIn reader to capture who sent each message and what each notification is about, not just badge counts.
21
+ - Added a review of the mobile companion spec, with follow-up tasks and open decisions.
22
+ - Added a "superseded" status so a redone marketing post replaces the old one on the Marketing orb.
23
+ - Added the pick tray to the Marketing page, showing captures with switchable blur boxes and one-click media selection.
24
+ - Fixed Home cards erasing what you were typing in "answer in your own words" whenever the page updated.
25
+ - Fixed tall question cards on Home overlapping the working-agents row below them.
26
+ - Changed where Symbiot keeps your AI key and app token so agent runs can no longer read them.
27
+
5
28
  ## 0.58.2
6
29
 
7
30
  - **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/LICENSE CHANGED
@@ -1,21 +1,98 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Garth White (GhostAI)
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ Copyright (c) 2026 Garth White (Ghost AI)
2
+
3
+ Symbiot is licensed under the Elastic License 2.0, below. Versions up to and
4
+ including 0.58.2 were released under the MIT License and stay under it.
5
+
6
+ Elastic License 2.0
7
+
8
+ URL: https://www.elastic.co/licensing/elastic-license
9
+
10
+ ## Acceptance
11
+
12
+ By using the software, you agree to all of the terms and conditions below.
13
+
14
+ ## Copyright License
15
+
16
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
17
+ non-sublicensable, non-transferable license to use, copy, distribute, make
18
+ available, and prepare derivative works of the software, in each case subject to
19
+ the limitations and conditions below.
20
+
21
+ ## Limitations
22
+
23
+ You may not provide the software to third parties as a hosted or managed
24
+ service, where the service provides users with access to any substantial set of
25
+ the features or functionality of the software.
26
+
27
+ You may not move, change, disable, or circumvent the license key functionality
28
+ in the software, and you may not remove or obscure any functionality in the
29
+ software that is protected by the license key.
30
+
31
+ You may not alter, remove, or obscure any licensing, copyright, or other notices
32
+ of the licensor in the software. Any use of the licensor’s trademarks is subject
33
+ to applicable law.
34
+
35
+ ## Patents
36
+
37
+ The licensor grants you a license, under any patent claims the licensor can
38
+ license, or becomes able to license, to make, have made, use, sell, offer for
39
+ sale, import and have imported the software, in each case subject to the
40
+ limitations and conditions in this license. This license does not cover any
41
+ patent claims that you cause to be infringed by modifications or additions to
42
+ the software. If you or your company make any written claim that the software
43
+ infringes or contributes to infringement of any patent, your patent license for
44
+ the software granted under these terms ends immediately. If your company makes
45
+ such a claim, your patent license ends immediately for work on behalf of your
46
+ company.
47
+
48
+ ## Notices
49
+
50
+ You must ensure that anyone who gets a copy of any part of the software from you
51
+ also gets a copy of these terms.
52
+
53
+ If you modify the software, you must include in any modified copies of the
54
+ software prominent notices stating that you have modified the software.
55
+
56
+ ## No Other Rights
57
+
58
+ These terms do not imply any licenses other than those expressly granted in
59
+ these terms.
60
+
61
+ ## Termination
62
+
63
+ If you use the software in violation of these terms, such use is not licensed,
64
+ and your licenses will automatically terminate. If the licensor provides you
65
+ with a notice of your violation, and you cease all violation of this license no
66
+ later than 30 days after you receive that notice, your licenses will be
67
+ reinstated retroactively. However, if you violate these terms after such
68
+ reinstatement, any additional violation of these terms will cause your licenses
69
+ to terminate automatically and permanently.
70
+
71
+ ## No Liability
72
+
73
+ *As far as the law allows, the software comes as is, without any warranty or
74
+ condition, and the licensor will not be liable to you for any damages arising
75
+ out of these terms or the use or nature of the software, under any kind of
76
+ legal claim.*
77
+
78
+ ## Definitions
79
+
80
+ The **licensor** is the entity offering these terms, and the **software** is the
81
+ software the licensor makes available under these terms, including any portion
82
+ of it.
83
+
84
+ **you** refers to the individual or entity agreeing to these terms.
85
+
86
+ **your company** is any legal entity, sole proprietorship, or other kind of
87
+ organization that you work for, plus all organizations that have control over,
88
+ are under the control of, or are under common control with that
89
+ organization. **control** means ownership of substantially all the assets of an
90
+ entity, or the power to direct its management and policies by vote, contract, or
91
+ otherwise. Control can be direct or indirect.
92
+
93
+ **your licenses** are all the licenses granted to you for the software under
94
+ these terms.
95
+
96
+ **use** means anything you do with the software requiring one of your licenses.
97
+
98
+ **trademark** means trademarks, service marks, and similar rights.
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
@@ -1417,6 +1419,32 @@ expects. Symbiot rescales for `xdotool` and for `cliclick` on a Retina screen, b
1417
1419
  it can't read the scale on Wayland, and ydotool's moves follow your pointer
1418
1420
  acceleration, so a click there can land off target.
1419
1421
 
1422
+ ## Symbiot Free and Pro
1423
+
1424
+ Every install starts with **14 days of Pro**, no key and no card. After that Symbiot
1425
+ carries on as **Free**:
1426
+
1427
+ - the orb, Home and Talk to Symbiot (typed or spoken), your week, your standup and
1428
+ what's on your plate, the Away screen;
1429
+ - **one agent at a time**, in **up to 3 projects** (the first 3 it works in; Settings →
1430
+ Symbiot Pro can free a slot for another);
1431
+ - **one connected inbox or chat**.
1432
+
1433
+ **Pro** (US$12 a month or US$99 a year) adds any number of agents at once, every project,
1434
+ every inbox and chat (all your mail, WhatsApp, LinkedIn, calendars), the marketing lane
1435
+ and Approve-and-ship. Get it from [symbiot.co.za](https://symbiot.co.za/#pro) and paste
1436
+ the key you're sent into **Settings → Symbiot Pro**. A limit never stops anything already
1437
+ running: it only keeps the next thing from starting, and says why.
1438
+
1439
+ Keys are checked on your computer (they're signed by Ghost AI; there's no account and no
1440
+ server). Symbiot never works on its own code (its repo, a fork of it, or its installed
1441
+ files), so it can't be asked to take out its own limits.
1442
+
1443
+ **Licence.** From 0.59.0 Symbiot is under the [Elastic License 2.0](LICENSE): free to use,
1444
+ change and share, but not to get round the licence key, sell it as a hosted service, or
1445
+ remove its notices. Versions up to and including 0.58.2 were released under MIT and stay
1446
+ under it.
1447
+
1420
1448
  ## Config, development and releasing
1421
1449
 
1422
1450
  Config files, environment variables (`SYMBIOT_MODEL`, `SYMBIOT_PORT`, …), the test
package/agents.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // Handing a repo to the user's agent: the saved command template, the one-click
2
2
  // presets (and the Orca IDE one), the background-job registry behind the Agents
3
3
  // tab, and the questions an unattended agent leaves for the user.
4
+ import { MARKETING_DIR } from "./marketing.mjs";
5
+ import { licenceState, isPro, can, canWorkIn } from "./licence.mjs";
4
6
  import { spawn } from "node:child_process";
5
7
  import { homedir } from "node:os";
6
8
  import { join, dirname, resolve, relative } from "node:path";
@@ -190,7 +192,7 @@ function withTrust(tmpl, box = null) {
190
192
  // config.agentSandbox: false turns it off.
191
193
  const SANDBOX_DIR = join(CONFIG_DIR, "sandbox");
192
194
  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
195
+ 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
196
  // Local sockets are allowed (headless Chrome needs one to start, so the browser tests
195
197
  // ran in no sandboxed run), but the ones that would be a way out are hidden: Docker's,
196
198
  // and your session's (its bus starts commands outside, through systemd-run; X11 and
@@ -264,12 +266,15 @@ function noteSession(path, code, resumed) {
264
266
  // A folder whose last run stopped on questions, with nothing changed since
265
267
  // that could answer them, gets no run: { blocked, questions, note }
266
268
  // (blockedAgain). force starts one anyway (something changed elsewhere).
267
- function runHandoff(repoPath, { force = false } = {}) {
269
+ // A folder with nothing to work from gets none either, forced or not (nothingToDo).
270
+ function runHandoff(repoPath, { force = false, lost = false } = {}) {
268
271
  let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
269
272
  if (isParked(repoPath)) return { blocked: true, parked: true, questions: 0, note: PARKED_NOTE }; // even forced: that's what parking is for
273
+ const lic = licenceGate(repoPath); if (lic) return lic; // Free's limits, and never Symbiot's own code (licence.mjs); even forced
270
274
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
271
275
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
272
276
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
277
+ const idle = !lost && nothingToDo(repoPath); if (idle) return { blocked: true, idle: true, questions: 0, note: idle };
273
278
  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
279
  tmpl = withStream(withTrust(scoped, box));
275
280
  const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null;
@@ -280,9 +285,10 @@ function runHandoff(repoPath, { force = false } = {}) {
280
285
  noteUntracked(repoPath);
281
286
  const lock = join(repoPath, ".symbiot", LOCK);
282
287
  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
- const env = own || boxEnv ? { ...(own || {}), ...(boxEnv || {}) } : null;
288
+ const owner = licenceState().plan === "owner" ? { SYMBIOT_OWNER: "1" } : null; // the guard lets only the owner's runs touch Symbiot's own files
289
+ const env = own || boxEnv || owner ? { ...(own || {}), ...(boxEnv || {}), ...(owner || {}) } : null;
284
290
  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
291
+ if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
286
292
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
287
293
  noteBlocked(repoPath, tmpl, e.startedAt, code);
288
294
  setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
@@ -296,6 +302,44 @@ function runHandoff(repoPath, { force = false } = {}) {
296
302
  noteRun(e);
297
303
  return e;
298
304
  }
305
+ // ---- nothing to do: no run ----------------------------------------------------
306
+ // A run in ~ started with no .symbiot/TASKS.md there, found nothing to action and
307
+ // only cost a session (2026-10-07). A run needs something to work from: an
308
+ // unticked task, or an answer that came in since the folder's last run started
309
+ // (an approved post or draft, a reply to its question: those carry on with every
310
+ // task ticked, and Marketing's folder may have no TASKS.md at all). Else the note
311
+ // for { blocked, idle }, even forced: Start it anyway would start the same
312
+ // nothing. Briefs are written before their run starts (actNow, draftReply,
313
+ // pushTasks), and a held one lands first (runHandoff), so they count. Only the
314
+ // run that restarts a lost conversation skips this: its answer is older than the
315
+ // failed resume's start.
316
+ const OPEN_TASK = /^\s*(?:[-*+]|\d+[.)])\s*\[ \]/m;
317
+ function nothingToDo(path) {
318
+ const md = readSymbiot(path, "TASKS.md"); if (OPEN_TASK.test(md)) return "";
319
+ let answered = 0; try { answered = statSync(join(path, ".symbiot", "ANSWERS.md")).mtimeMs; } catch {}
320
+ const last = [...readSymbiot(path, "agent.log").matchAll(/^=== .* (\d{4}-\d\d-\d\dT[\d:.]+Z) ===$/gm)].pop();
321
+ if (answered && answered > (last ? Date.parse(last[1]) || 0 : 0)) return "";
322
+ const name = path === homedir() ? "~" : path.split(/[\\/]/).filter(Boolean).pop() || path;
323
+ 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.`
324
+ : `Not started: ${name} has no .symbiot/TASKS.md, so an agent there would have nothing to do. Send it a task first.`;
325
+ }
326
+ // ---- Symbiot Free and Pro (licence.mjs) -------------------------------------
327
+ // A run that Free doesn't cover doesn't start; it says why, the way a parked lane does.
328
+ // Nothing running is touched. Free's 3 projects are the first 3 it works in (Settings
329
+ // can free one); the marketing lane is Pro.
330
+ function licenceGate(repoPath, st = licenceState()) {
331
+ const self = canWorkIn(repoPath, st); if (!self.ok) return { blocked: true, self: true, questions: 0, note: self.why };
332
+ if (isPro(st)) return null;
333
+ if (repoPath === MARKETING_DIR) { const m = can("marketing", {}, st); return { blocked: true, pro: true, questions: 0, note: m.why }; }
334
+ const running = HANDOFFS.filter((x) => x.handoff && x.status === "running" && x.path !== repoPath).length;
335
+ const a = can("agents", { running }, st); if (!a.ok) return { blocked: true, pro: true, questions: 0, note: a.why };
336
+ const cfg = loadConfig(), L = cfg.licence || {}, mine = Array.isArray(L.projects) ? L.projects : [];
337
+ if (!mine.includes(repoPath)) {
338
+ const p = can("projects", { count: mine.length }, st); if (!p.ok) return { blocked: true, pro: true, questions: 0, note: p.why + " Settings → Symbiot Pro can swap one of your 3 for this." };
339
+ cfg.licence = { ...L, projects: [...mine, repoPath] }; saveConfig(cfg);
340
+ }
341
+ return null;
342
+ }
299
343
  // ---- a parked lane: its tasks start no runs ----------------------------------
300
344
  // A lane blocked on something only the user can give (a token from Meta) ran 27
301
345
  // times: every answer, handover, Go and Start it anyway started it again, to say
@@ -321,7 +365,12 @@ function parkLane(path, on) {
321
365
  // test stopped halfway would be void), stops the urgent lane's own routine run (its
322
366
  // changes stay in the folder, and the next run carries on from them) so the urgent
323
367
  // 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.
368
+ // old order and starts the ones with work waiting. Lanes you parked stay parked,
369
+ // except the urgent lane itself: on 2026-10-09 symbiot was parked, so every urgent
370
+ // handover there parked marketing, started nothing, and still said "it's already on
371
+ // urgent work, and takes this next" while 0 agents worked. Asking for urgent work in
372
+ // a lane is the newest thing you've said about it, so it's unparked for it; and if
373
+ // its run still doesn't start, urgentUndo takes back what urgentFirst did.
325
374
  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
375
  function isUrgent(text) {
327
376
  const t = String(text || "");
@@ -338,24 +387,43 @@ function stopRun(path) {
338
387
  try { process.kill(-r.pid, "SIGTERM"); } catch { try { process.kill(r.pid, "SIGTERM"); } catch { return false; } } // its own process group (track: detached)
339
388
  return true;
340
389
  }
341
- // lanes: the paths to consider ({ name: path }); returns { parked: [names], stopped }
390
+ // lanes: the paths to consider ({ name: path }); returns { parked: [names], parkedPaths,
391
+ // stopped, unparked (it was parked: now it isn't), busy: "urgent" | "routine" | "" (a run
392
+ // there now: one already on urgent work, which this waits for, or one it couldn't stop) }
342
393
  function urgentFirst(path, { lanes = null, stop = stopRun, running = runningHandoff } = {}) {
343
- if (!path) return { parked: [], stopped: false };
394
+ if (!path) return { parked: [], parkedPaths: [], stopped: false, unparked: false, busy: "" };
344
395
  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
396
+ if (!u.paths.includes(path)) { u.paths.push(path); u.at = Date.now(); }
397
+ if (u.parked.includes(path)) u.parked = u.parked.filter((p) => p !== path); // urgent itself now
398
+ const unparked = isParked(path); if (unparked) parkLane(path, false);
347
399
  const all = lanes || Object.fromEntries(loadRuns().map((r) => [String(r.name || r.path.split("/").pop()), r.path]));
348
- const parked = [], already = parkedPaths();
400
+ const parked = [], mine = [], already = parkedPaths();
349
401
  for (const [name, p] of Object.entries(all)) {
350
402
  if (!p || u.paths.includes(p) || already.includes(p)) continue; // yours stay yours
351
403
  if (!running(p) && !readSymbiot(p, HELD)) continue; // nothing going or waiting there
352
- parkLane(p, true); u.parked.push(p); parked.push(name);
404
+ parkLane(p, true); u.parked.push(p); parked.push(name); mine.push(p);
353
405
  }
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")));
406
+ // its own lane: a routine run stops so this one starts now; one that's already on urgent
407
+ // work keeps going, and counts as the urgent run, so its end resumes what was parked
408
+ const busy = running(path), onIt = !!busy && (u.runs.includes(busy.id) || hasOpenUrgent(readSymbiot(path, "TASKS.md")));
409
+ if (onIt && busy.id && !u.runs.includes(busy.id)) u.runs.push(busy.id);
356
410
  const stopped = !!busy && !onIt && stop(path);
357
411
  saveUrgent(u);
358
- return { parked, stopped };
412
+ return { parked, parkedPaths: mine, stopped, unparked, busy: !busy ? "" : onIt ? "urgent" : stopped ? "" : "routine" };
413
+ }
414
+ // The urgent lane's run didn't start (no agent command, a step of yours first, its
415
+ // questions unanswered) and nothing runs there: nothing will end to resume the lanes
416
+ // urgentFirst parked, so they're unparked now, and the lane leaves the urgent record.
417
+ function urgentUndo(path, did, { running = runningHandoff } = {}) {
418
+ if (!path || !did || running(path)) return false;
419
+ const u = urgentState(); if (!u) return false;
420
+ const back = (did.parkedPaths || []).filter((p) => u.parked.includes(p));
421
+ for (const p of back) if (parkedPaths().includes(p)) parkLane(p, false);
422
+ u.parked = u.parked.filter((p) => !back.includes(p));
423
+ u.paths = u.paths.filter((p) => p !== path);
424
+ saveUrgent(u.paths.length ? u : null);
425
+ if (!u.paths.length && u.parked.length) resumeParked(u.parked); // parked by an earlier urgent ask that never ran either
426
+ return true;
359
427
  }
360
428
  // the urgent lane's run started: when it ends, the parked work resumes (runHandoff's exit)
361
429
  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 +444,13 @@ function resumeParked(paths, start = runHandoff) {
376
444
  return { unparked: paths, resumed };
377
445
  }
378
446
  // 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;
447
+ // the parked lanes don't wait forever. A record with no run going in any urgent lane is
448
+ // over once URGENT_GRACE has passed (a stopped routine run takes a moment to exit, and
449
+ // the urgent one starts then): before, one whose run never started held marketing for 6h.
450
+ const URGENT_MAX = 6 * 3600 * 1000, URGENT_GRACE = 2 * 60 * 1000;
381
451
  function urgentSweep({ running = runningHandoff, now = Date.now(), start = runHandoff } = {}) {
382
452
  const u = urgentState(); if (!u) return null;
383
- const ended = u.runs.length > 0 && !u.paths.some((p) => running(p));
453
+ const ended = !u.paths.some((p) => running(p)) && (u.runs.length > 0 || now - (u.at || 0) >= URGENT_GRACE);
384
454
  if (!ended && now - (u.at || 0) < URGENT_MAX) return null;
385
455
  saveUrgent(null);
386
456
  return resumeParked(u.parked, start);
@@ -999,7 +1069,7 @@ const HARM_RULE = /^Bash\(\s*(rm|dd|mkfs|chmod|chown|shred|truncate)\b/i;
999
1069
  const wideRule = (r) => ANY_RULE.test(r) || (HARM_RULE.test(r) && /\*/.test(r));
1000
1070
  // Whatever an agent is allowed, it never edits Symbiot's own settings: that's where
1001
1071
  // 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})`]; };
1072
+ 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
1073
  function installAllowlist(path) {
1004
1074
  let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return null; }
1005
1075
  const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
@@ -1190,4 +1260,4 @@ function agentsList() {
1190
1260
  }).concat(earlierRuns());
1191
1261
  }
1192
1262
 
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 };
1263
+ 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 , licenceGate };
package/claudesetup.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // sign-in page in the browser and finishes by itself once they've signed in. If the
5
5
  // page shows a code instead (the browser couldn't hand it back), setup has a box for
6
6
  // it, which goes to the sign-in that's waiting. SYMBIOT_CLAUDE_INSTALL_CMD replaces
7
- // the installer (tests).
7
+ // the installer (tests); SYMBIOT_NO_SIGNIN turns both off (the smoke test presses every button).
8
8
  import { spawn } from "node:child_process";
9
9
  import { homedir } from "node:os";
10
10
  import { claudeState, claudeSpawn } from "./ai.mjs";
@@ -19,6 +19,7 @@ function installCmd(platform = process.platform) {
19
19
  }
20
20
  function installClaude() {
21
21
  if (S.installing) return claudeSetup();
22
+ if (process.env.SYMBIOT_NO_SIGNIN && !process.env.SYMBIOT_CLAUDE_INSTALL_CMD) { S.installError = "Installing is switched off here (SYMBIOT_NO_SIGNIN)."; return claudeSetup(); }
22
23
  S.installing = true; S.installError = ""; S.log = "";
23
24
  const c = installCmd();
24
25
  let ch; try { ch = spawn(c.cmd, c.args, { cwd: homedir(), shell: c.shell, windowsHide: true, env: process.env }); } catch (e) { S.installing = false; S.installError = String((e && e.message) || e); return claudeSetup(); }
@@ -33,6 +34,8 @@ function installClaude() {
33
34
  }
34
35
  function signInClaude() {
35
36
  if (S.signingIn) return claudeSetup();
37
+ // tests press every button: a real sign-in would open the browser on someone's real account
38
+ if (process.env.SYMBIOT_NO_SIGNIN) { S.signinError = "Signing in is switched off here (SYMBIOT_NO_SIGNIN)."; return claudeSetup(); }
36
39
  S.signingIn = true; S.signinError = "";
37
40
  let ch; try { ch = claudeSpawn(["auth", "login", "--claudeai"], { cwd: homedir(), stdio: ["pipe", "pipe", "pipe"], env: process.env }); } catch (e) { S.signingIn = false; S.signinError = String((e && e.message) || e); return claudeSetup(); }
38
41
  S.child = ch;
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,13 +19,18 @@ 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;
25
26
  const SCP_VALUE = /^-[346ABCOpqRrTv]*[iFoPSJcl]$/; // an scp option (alone or after flags, -rpi) whose value is the next word
26
27
  function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
27
- function symbiotConfig(p, home) { return p === join(home, ".config", "symbiot", "config.json"); }
28
+ function symbiotConfig(p, home) { const d = join(home, ".config", "symbiot"); return p === join(d, "config.json") || p === join(d, "licence-revoked.json"); }
29
+ // Symbiot's own installed files (where this guard runs from): never edited by an agent, so
30
+ // it can't be asked to take out its own limits or rules. Ghost AI's owner key lets its own
31
+ // runs through (SYMBIOT_OWNER, set by Symbiot, not by the agent).
32
+ const SELF = resolve(fileURLToPath(new URL(".", import.meta.url)));
33
+ const selfFile = (p) => !process.env.SYMBIOT_OWNER && under(p, SELF);
28
34
 
29
35
  // the branch a bare `git push` pushes: the one checked out in cwd
30
36
  function currentBranch(cwd) { try { return execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } }
@@ -36,6 +42,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
36
42
  const s = part.trim(); if (!s) continue;
37
43
  const w = s.split(/\s+/);
38
44
  if (/^(sudo|su|doas)$/.test(w[0])) return { why: "sudo: only you run things as root" };
45
+ if (!process.env.SYMBIOT_OWNER && (s.includes(SELF + "/") || s.includes(SELF + " ") || s.endsWith(SELF))) return { why: "Symbiot doesn't edit its own code" };
39
46
  if (/^(shutdown|reboot|poweroff|halt)$/.test(w[0])) return { why: "shutting the computer down is yours to do" };
40
47
  if (/^(mkfs(\.\w+)?|fdisk|parted|wipefs)$/.test(w[0]) || (/^dd$/.test(w[0]) && /\bof=\/dev\//.test(s))) return { why: "that writes a disk directly" };
41
48
  if (/^git$/.test(w[0]) || /^git\s/.test(s)) {
@@ -65,7 +72,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
65
72
  const own = w[0] === "scp" ? SCP_VALUE : null;
66
73
  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
74
  }
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" };
75
+ 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
76
  }
70
77
  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
78
  // a browser on the user's running Symbiot: each page it opened counted as a new window and
@@ -82,6 +89,7 @@ function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch
82
89
  const abs = expand(p, home, cwd);
83
90
  if (secretPath(abs, home)) return { why: "your keys and cloud credentials stay yours" };
84
91
  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && symbiotConfig(abs, home)) return { why: "Symbiot's own settings are yours to change" };
92
+ if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && selfFile(abs)) return { why: "Symbiot doesn't edit its own code" };
85
93
  if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && Array.isArray(writes) && writes.length && !writes.some((d) => under(abs, d))) return { why: "this run writes only in its repo and your folders (its sandbox), not " + p };
86
94
  return null;
87
95
  }