symbiot 0.58.3 → 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,12 @@
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`).
5
11
  ## 0.58.3 — 2026-10-09
6
12
 
7
13
  - Fixed urgent requests to a parked project: it is now unparked for the work, and other projects are released if nothing starts.
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
@@ -1419,6 +1419,32 @@ expects. Symbiot rescales for `xdotool` and for `cliclick` on a Retina screen, b
1419
1419
  it can't read the scale on Wayland, and ydotool's moves follow your pointer
1420
1420
  acceleration, so a click there can land off target.
1421
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
+
1422
1448
  ## Config, development and releasing
1423
1449
 
1424
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";
@@ -268,6 +270,7 @@ function noteSession(path, code, resumed) {
268
270
  function runHandoff(repoPath, { force = false, lost = false } = {}) {
269
271
  let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
270
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
271
274
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
272
275
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
273
276
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
@@ -282,7 +285,8 @@ function runHandoff(repoPath, { force = false, lost = false } = {}) {
282
285
  noteUntracked(repoPath);
283
286
  const lock = join(repoPath, ".symbiot", LOCK);
284
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);
285
- 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;
286
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) => {
287
291
  if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
288
292
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
@@ -319,6 +323,23 @@ function nothingToDo(path) {
319
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.`
320
324
  : `Not started: ${name} has no .symbiot/TASKS.md, so an agent there would have nothing to do. Send it a task first.`;
321
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
+ }
322
343
  // ---- a parked lane: its tasks start no runs ----------------------------------
323
344
  // A lane blocked on something only the user can give (a token from Meta) ran 27
324
345
  // times: every answer, handover, Go and Start it anyway started it again, to say
@@ -1239,4 +1260,4 @@ function agentsList() {
1239
1260
  }).concat(earlierRuns());
1240
1261
  }
1241
1262
 
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 };
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/guard.mjs CHANGED
@@ -25,7 +25,12 @@ const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, ho
25
25
  const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
26
26
  const SCP_VALUE = /^-[346ABCOpqRrTv]*[iFoPSJcl]$/; // an scp option (alone or after flags, -rpi) whose value is the next word
27
27
  function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
28
- 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);
29
34
 
30
35
  // the branch a bare `git push` pushes: the one checked out in cwd
31
36
  function currentBranch(cwd) { try { return execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } }
@@ -37,6 +42,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
37
42
  const s = part.trim(); if (!s) continue;
38
43
  const w = s.split(/\s+/);
39
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" };
40
46
  if (/^(shutdown|reboot|poweroff|halt)$/.test(w[0])) return { why: "shutting the computer down is yours to do" };
41
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" };
42
48
  if (/^git$/.test(w[0]) || /^git\s/.test(s)) {
@@ -83,6 +89,7 @@ function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch
83
89
  const abs = expand(p, home, cwd);
84
90
  if (secretPath(abs, home)) return { why: "your keys and cloud credentials stay yours" };
85
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" };
86
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 };
87
94
  return null;
88
95
  }
package/licence.mjs ADDED
@@ -0,0 +1,144 @@
1
+ // symbiot — Symbiot Free and Symbiot Pro.
2
+ //
3
+ // Free: the orb and Home, the week, standups, voice, the Away screen, one agent at a
4
+ // time, up to 3 projects and one connected inbox. Pro: any number of agents at once,
5
+ // every project, every inbox and chat, the marketing lane and Approve-and-ship.
6
+ //
7
+ // A Pro key is a few lines of text signed by Ghost AI's private key (Ed25519); every
8
+ // copy of Symbiot carries the public key below and checks keys offline: no account,
9
+ // no server. Keys can carry an end date; a key that's been cancelled is listed at
10
+ // symbiot.co.za/licences/revoked.json, which Symbiot reads about once a day (missing
11
+ // it changes nothing). Everyone gets a 14-day Pro trial, from the first time this
12
+ // version runs. Nothing already running is ever stopped: a limit only stops the next
13
+ // thing from starting, with a line saying why and what Pro adds.
14
+ //
15
+ // Symbiot never works on its own code (its repo, a fork of it, or its installed
16
+ // files), so it can't be asked to take its own limits out; Ghost AI's owner key is
17
+ // the exception. This is part of the licence (Elastic License 2.0: no getting round
18
+ // the licence key).
19
+ //
20
+ // SYMBIOT_LICENCE_PUBLIC_KEY replaces the public key (tests sign with their own).
21
+ import { createPublicKey, verify as edVerify } from "node:crypto";
22
+ import { existsSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
23
+ import { join, dirname, resolve, sep } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { spawnSync } from "node:child_process";
26
+ import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
27
+
28
+ const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
29
+ MCowBQYDK2VwAyEAGLISPmrOCX+t/KVY/u99rmFhjbXFbvRCGw1tStG1t8A=
30
+ -----END PUBLIC KEY-----`;
31
+ const REVOKED_URL = "https://symbiot.co.za/licences/revoked.json";
32
+ const TRIAL_DAYS = 14, GRACE_DAYS = 7, DAY = 86400000;
33
+ const FREE = { agents: 1, projects: 3, inboxes: 1 };
34
+ const PRICE = "US$12 a month or US$99 a year";
35
+ const UPGRADE_URL = "https://symbiot.co.za/#pro";
36
+
37
+ const unb64u = (s) => Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
38
+ const pubKey = () => createPublicKey(process.env.SYMBIOT_LICENCE_PUBLIC_KEY || PUBLIC_KEY);
39
+
40
+ // "SYM1-<payload>.<signature>", whitespace and line breaks ignored (keys get pasted from mail).
41
+ function readKey(text) {
42
+ const t = String(text || "").replace(/\s+/g, "");
43
+ const m = /^SYM1-([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/.exec(t);
44
+ if (!m) return { ok: false, why: "That isn't a Symbiot key. It starts with SYM1-." };
45
+ let p;
46
+ try {
47
+ if (!edVerify(null, Buffer.from(m[1]), pubKey(), unb64u(m[2]))) return { ok: false, why: "That key wasn't issued by Ghost AI (its signature doesn't match)." };
48
+ p = JSON.parse(unb64u(m[1]).toString("utf8"));
49
+ } catch { return { ok: false, why: "That key is damaged. Copy it again, all of it." }; }
50
+ if (!p || p.v !== 1 || !["pro", "owner"].includes(p.p) || !p.id) return { ok: false, why: "That key is for something this version of Symbiot doesn't know." };
51
+ return { ok: true, key: t, id: String(p.id), plan: p.p, email: String(p.e || ""), name: String(p.n || ""), issued: Number(p.i) || 0, expires: Number(p.x) || 0 };
52
+ }
53
+
54
+ // Cancelled keys, from symbiot.co.za, refreshed at most once a day; a failed fetch keeps the last list.
55
+ const REVOKED_FILE = () => join(CONFIG_DIR, "licence-revoked.json");
56
+ function revokedIds() { try { return new Set(JSON.parse(readFileSync(REVOKED_FILE(), "utf8")).ids || []); } catch { return new Set(); } }
57
+ async function refreshRevoked(now = Date.now()) {
58
+ try {
59
+ const cur = existsSync(REVOKED_FILE()) ? JSON.parse(readFileSync(REVOKED_FILE(), "utf8")) : {};
60
+ if (cur.at && now - cur.at < DAY) return false;
61
+ const r = await fetch(process.env.SYMBIOT_REVOKED_URL || REVOKED_URL, { signal: AbortSignal.timeout(8000) });
62
+ if (!r.ok) return false;
63
+ const j = await r.json();
64
+ writeFileSync(REVOKED_FILE(), JSON.stringify({ at: now, ids: Array.isArray(j.ids) ? j.ids.map(String) : [] }));
65
+ return true;
66
+ } catch { return false; }
67
+ }
68
+
69
+ // Where you stand: { plan: "pro" | "owner" | "trial" | "free", ... }. Starts the trial clock on first sight.
70
+ function licenceState(now = Date.now(), cfg = loadConfig()) {
71
+ const L = cfg.licence || {};
72
+ if (!L.trialStart) { L.trialStart = now; cfg.licence = L; try { saveConfig(cfg); } catch {} }
73
+ const base = { limits: FREE, price: PRICE, upgrade: UPGRADE_URL };
74
+ if (L.key) {
75
+ const k = readKey(L.key);
76
+ if (k.ok && revokedIds().has(k.id)) return { ...base, plan: "free", keyProblem: "This key has been cancelled. If that's a mistake, reply to the email it came in." };
77
+ if (k.ok) {
78
+ const left = k.expires ? k.expires - now : Infinity;
79
+ const who = { email: k.email, name: k.name, keyId: k.id, ...(k.expires ? { expires: k.expires } : {}) };
80
+ if (left > 0) return { ...base, plan: k.plan, ...who, ...(left < 14 * DAY ? { renewSoon: Math.ceil(left / DAY) } : {}) };
81
+ if (left > -GRACE_DAYS * DAY) return { ...base, plan: k.plan, ...who, grace: Math.ceil((left + GRACE_DAYS * DAY) / DAY) };
82
+ return { ...base, plan: "free", ...who, keyProblem: "Your Pro key has ended. Renew it to keep Pro." };
83
+ }
84
+ return { ...base, plan: "free", keyProblem: k.why };
85
+ }
86
+ const trialLeft = L.trialStart + TRIAL_DAYS * DAY - now;
87
+ if (trialLeft > 0) return { ...base, plan: "trial", trialDaysLeft: Math.ceil(trialLeft / DAY) };
88
+ return { ...base, plan: "free", trialEnded: true };
89
+ }
90
+ const isPro = (st = licenceState()) => st.plan === "pro" || st.plan === "owner" || st.plan === "trial";
91
+
92
+ function setKey(text) {
93
+ const k = readKey(text);
94
+ if (!k.ok) return { error: k.why };
95
+ if (revokedIds().has(k.id)) return { error: "This key has been cancelled." };
96
+ const cfg = loadConfig(); cfg.licence = { ...(cfg.licence || {}), key: k.key }; saveConfig(cfg);
97
+ return licenceState();
98
+ }
99
+ function clearKey() { const cfg = loadConfig(); if (cfg.licence) { delete cfg.licence.key; saveConfig(cfg); } return licenceState(); }
100
+
101
+ // What Free stops, and how it says so. ctx: { running } agents now, { count } projects/inboxes now.
102
+ const WHY = {
103
+ agents: (n) => `Symbiot Free runs one agent at a time, and one is at work. This one starts when it's done, or Pro runs as many as you like at once (${PRICE}).`,
104
+ projects: () => `Symbiot Free works in ${FREE.projects} projects. Pro works in all of them (${PRICE}).`,
105
+ inboxes: () => `Symbiot Free connects one inbox or chat. Pro connects all of them: every mail account, WhatsApp, LinkedIn and your calendars (${PRICE}).`,
106
+ marketing: () => `The marketing lane is part of Symbiot Pro (${PRICE}).`,
107
+ ship: () => `Approve-and-ship (Symbiot opening the pull request and shipping it) is part of Symbiot Pro (${PRICE}). Your agent's changes are kept on their branch either way.`,
108
+ };
109
+ function can(feature, ctx = {}, st = licenceState()) {
110
+ if (isPro(st)) return { ok: true };
111
+ const n = Number(ctx.count ?? ctx.running ?? 0);
112
+ if (feature === "agents" && n < FREE.agents) return { ok: true };
113
+ if (feature === "projects" && n < FREE.projects) return { ok: true };
114
+ if (feature === "inboxes" && n < FREE.inboxes) return { ok: true };
115
+ if (!WHY[feature]) return { ok: true };
116
+ return { ok: false, pro: true, feature, why: WHY[feature](n), upgrade: UPGRADE_URL };
117
+ }
118
+
119
+ // ---- Symbiot never works on its own code -------------------------------------
120
+ const HERE = (() => { try { return realpathSync(dirname(fileURLToPath(import.meta.url))); } catch { return dirname(fileURLToPath(import.meta.url)); } })();
121
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
122
+ const inside = (p, root) => { const a = real(p), b = real(root); return a === b || a.startsWith(b + sep); };
123
+ // Its repo or a fork (whatever it's renamed to), or the copy of Symbiot that's installed.
124
+ function isSymbiotCode(dir) {
125
+ if (!dir) return false;
126
+ const d = real(dir);
127
+ if (inside(d, HERE)) return true;
128
+ let top = d;
129
+ try { const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: d, encoding: "utf8", timeout: 5000, windowsHide: true }); if (r.status === 0 && r.stdout.trim()) top = r.stdout.trim(); } catch {}
130
+ try { const pj = JSON.parse(readFileSync(join(top, "package.json"), "utf8")); if (pj.name === "symbiot" || pj.name === "symbiot-desktop") return true; } catch {}
131
+ const has = (f) => existsSync(join(top, f));
132
+ if (has("licence.mjs") && has("ui.mjs") && has("server.mjs")) return true;
133
+ try { if (/EMBEDDED_UI/.test(readFileSync(join(top, "ui.mjs"), "utf8"))) return true; } catch {}
134
+ try { const r = spawnSync("git", ["remote", "-v"], { cwd: top, encoding: "utf8", timeout: 5000, windowsHide: true }); if (/[/:]symbiot(\.git)?\s/i.test(r.stdout || "")) return true; } catch {}
135
+ return false;
136
+ }
137
+ const SELF_WHY = "Symbiot doesn't work on its own code (Symbiot's repo, a copy of it, or its installed files). That keeps its licence and its safety rules from being edited away.";
138
+ function canWorkIn(dir, st = licenceState()) {
139
+ if (!isSymbiotCode(dir)) return { ok: true };
140
+ if (st.plan === "owner") return { ok: true };
141
+ return { ok: false, self: true, why: SELF_WHY };
142
+ }
143
+
144
+ export { readKey, licenceState, isPro, setKey, clearKey, can, refreshRevoked, isSymbiotCode, canWorkIn, FREE, PRICE, TRIAL_DAYS, SELF_WHY, HERE as SYMBIOT_HOME };
package/links.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  // trusted sites, there's no command an agent could call to link a site itself.
19
19
  //
20
20
  // What's linked is config.linked: { <id>: { at, watch?, hosts: [trusted by the link] } }.
21
+ import { can } from "./licence.mjs";
21
22
  import { join } from "node:path";
22
23
  import { readFileSync } from "node:fs";
23
24
  import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
@@ -100,10 +101,16 @@ function linksState() {
100
101
  // Link one: trust its hosts, watch its page, open it to sign in. Clicking a link
101
102
  // that's already there just opens it again to sign in (signed out, or a second
102
103
  // account). Gives { ok, url, item } or { error }.
104
+ // what counts as an inbox or chat for Free's one
105
+ const INBOX_GROUPS = new Set(["Mail", "Calendar", "Chat", "Social"]);
103
106
  async function linkSite(id, { open = signIn } = {}) {
104
107
  const e = catalog().list.find((x) => x.id === id);
105
108
  if (!e) return { error: `No link called ${id}.` };
106
109
  const l = linked(), had = l[id], before = new Set(trustedSites()), added = [];
110
+ if (!had && INBOX_GROUPS.has(e.group)) { // Free connects one inbox or chat (licence.mjs)
111
+ const n = Object.keys(l).filter((k) => { const x = catalog().list.find((c) => c.id === k); return x && INBOX_GROUPS.has(x.group); }).length;
112
+ const ok = can("inboxes", { count: n }); if (!ok.ok) return { error: ok.why, pro: true };
113
+ }
107
114
  for (const h of e.hosts) if (!before.has(h)) { const r = trustSite(h); if (r && r.ok) added.push(h); }
108
115
  let watch = had && had.watch;
109
116
  if (e.watch && !(watch && watchState().watches.some((x) => x.id === watch))) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.58.3",
4
- "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
3
+ "version": "0.59.0",
4
+ "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "symbiot": "index.mjs"
@@ -52,7 +52,8 @@
52
52
  "estimate.mjs",
53
53
  "icon.ico",
54
54
  "claudesetup.mjs",
55
- "voice.mjs"
55
+ "voice.mjs",
56
+ "licence.mjs"
56
57
  ],
57
58
  "engines": {
58
59
  "node": ">=18"
@@ -72,7 +73,7 @@
72
73
  "cli"
73
74
  ],
74
75
  "author": "Garth White <garth@ghost-ai.co.za>",
75
- "license": "MIT",
76
+ "license": "Elastic-2.0",
76
77
  "repository": {
77
78
  "type": "git",
78
79
  "url": "git+https://github.com/GarthGhostai/symbiot.git"
@@ -85,7 +86,7 @@
85
86
  "@anthropic-ai/sdk": "^0.128.0"
86
87
  },
87
88
  "scripts": {
88
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
89
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
89
90
  "lint": "eslint .",
90
91
  "postinstall": "node postinstall.mjs"
91
92
  },
package/server.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // (ui.mjs) and its /api routes, each a thin call into the module that does the
3
3
  // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
4
4
  // listens on your network separately, only for that: phone.mjs.)
5
+ import { licenceState, setKey, clearKey, can, refreshRevoked, FREE } from "./licence.mjs";
5
6
  import { claudeSetup, installClaude, signInClaude, sendClaudeCode } from "./claudesetup.mjs";
6
7
  import { voiceState, prepareVoice, speak, stopVoices } from "./voice.mjs";
7
8
  import { spawn, spawnSync } from "node:child_process";
@@ -152,6 +153,8 @@ const IN_TERMUX = process.platform === "android" && process.env.SYMBIOT_ANDROID_
152
153
  const SANDBOX = !!process.env.SYMBIOT_SANDBOX;
153
154
  // the installed app (symbiot-desktop): it updates itself and made its own shortcuts
154
155
  const DESKTOP = process.env.SYMBIOT_DESKTOP === "1"; // symbiot app --fresh (sandbox.mjs)
156
+ // the licence as Settings shows it: the plan, and Free's 3 projects
157
+ function licenceView() { const st = licenceState(); const L = loadConfig().licence || {}; return { ...st, projects: (L.projects || []).map((p) => ({ path: p, name: p.split(/[\\/]/).pop() })), free: FREE }; } // symbiot app --fresh (sandbox.mjs)
155
158
  const FIRST_RUN = !existsSync(CONFIG_PATH); // no config yet when Symbiot started: a brand-new install
156
159
  function openApp(url) {
157
160
  try {
@@ -238,6 +241,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
238
241
  if (FRESH) { try { const cf = loadConfig(); if (!cf.onboarding) { cf.onboarding = { pending: true, step: "welcome", skipped: [] }; saveConfig(cf); } } catch {} }
239
242
  // your projects: kept, and found again off the main thread, so nothing waits on a search
240
243
  setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
244
+ if (!SANDBOX) { refreshRevoked().catch(() => {}); setInterval(() => refreshRevoked().catch(() => {}), 6 * 3600 * 1000).unref(); } // cancelled Pro keys, about daily
241
245
  { const t0 = Date.now(); refreshRepos().then((l) => console.log(`Found ${l.length} project${l.length === 1 ? "" : "s"} in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)); }
242
246
  let UPDATING = null; // an update in flight: { target, attempt, retrying? }
243
247
  let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
@@ -390,14 +394,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
390
394
  }
391
395
  // Marketing's own lane (marketing.mjs): a task for it, tagged with its product; another
392
396
  // lane's marketing task moved to it; its agent started; a draft its agent wrote, opened
393
- if (u.pathname === "/api/marketing/task" && req.method === "POST") { const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
394
- if (u.pathname === "/api/marketing/move" && req.method === "POST") { const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
395
- if (u.pathname === "/api/marketing/go" && req.method === "POST") return json(res, marketingGo());
397
+ if (u.pathname === "/api/marketing/task" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
398
+ if (u.pathname === "/api/marketing/move" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
399
+ if (u.pathname === "/api/marketing/go" && req.method === "POST") { const g = can("marketing"); return json(res, g.ok ? marketingGo() : { error: g.why, pro: true }); }
396
400
  // a draft as the post it will be (its preview, notes apart), its picture or video, and
397
401
  // your Approve or Skip on it: approved, its agent posts it through Symbiot's browser
398
402
  if (u.pathname === "/api/marketing/draft") return json(res, draftPreview(String(u.searchParams.get("rel") || "")));
399
403
  if (u.pathname === "/api/marketing/media") { const m = laneMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=300" }); res.end(readFileSync(m.file)); return; }
400
- if (u.pathname === "/api/marketing/draft/answer" && req.method === "POST") { const b = await readBody(req); return json(res, marketingDraftAnswer(String(b.rel || ""), b.skip ? "skipped" : "approved")); }
404
+ if (u.pathname === "/api/marketing/draft/answer" && req.method === "POST") { { const g = can("marketing"); if (!g.ok) return json(res, { error: g.why, pro: true }); } const b = await readBody(req); return json(res, marketingDraftAnswer(String(b.rel || ""), b.skip ? "skipped" : "approved")); }
401
405
  if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
402
406
  // the pick tray (tray.mjs): its captures and their blur boxes, a capture (only from inside
403
407
  // a tray), a box switched on or off and the capture blurred again, a capture as a post's media
@@ -511,6 +515,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
511
515
  if (u.pathname === "/api/claude/install" && req.method === "POST") return json(res, installClaude());
512
516
  if (u.pathname === "/api/claude/signin" && req.method === "POST") return json(res, signInClaude());
513
517
  if (u.pathname === "/api/claude/code" && req.method === "POST") { const b = await readBody(req); return json(res, sendClaudeCode(b.code)); }
518
+ // Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, a Free project slot freed
519
+ if (u.pathname === "/api/licence") return json(res, licenceView());
520
+ if (u.pathname === "/api/licence/key" && req.method === "POST") { const b = await readBody(req); const r = setKey(b.key); return json(res, r.error ? r : licenceView()); }
521
+ if (u.pathname === "/api/licence/clear" && req.method === "POST") { clearKey(); return json(res, licenceView()); }
522
+ if (u.pathname === "/api/licence/project/free" && req.method === "POST") { const b = await readBody(req); const c = loadConfig(); const L = c.licence || {}; L.projects = (L.projects || []).filter((p) => p !== String(b.path || "")); c.licence = L; saveConfig(c); return json(res, licenceView()); }
514
523
  if (u.pathname === "/api/update" && req.method === "POST" && DESKTOP) return json(res, { error: "This Symbiot updates with its app." });
515
524
  if (u.pathname === "/api/update" && req.method === "POST") {
516
525
  // Install the exact newest version (see updateCmd), then relaunch this
package/tasks.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // each repo gets (.symbiot/TASKS.md), what the agent ticked (review), and
3
3
  // Approve: branch, commit, push, PR, and the version bump and npm release facts
4
4
  // it shows.
5
+ import { can } from "./licence.mjs";
5
6
  import { spawn, spawnSync } from "node:child_process";
6
7
  import { join } from "node:path";
7
8
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync } from "node:fs";
@@ -558,6 +559,7 @@ async function shipChanges(path, texts, opts = {}) {
558
559
  function autoMergeRepos() { const a = loadConfig().autoMerge; return Array.isArray(a) ? a : []; }
559
560
  function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArray(cfg.autoMerge) ? cfg.autoMerge : []).filter((x) => x !== repo); if (on && repo) a.push(repo); if (a.length) cfg.autoMerge = a; else delete cfg.autoMerge; saveConfig(cfg); return { ok: true, repos: cfg.autoMerge || [] }; }
560
561
  async function approveRepo(repo, opts = {}) {
562
+ const pro = can("ship"); if (!pro.ok) return { error: pro.why, pro: true }; // Pro (licence.mjs); the work stays on its branch
561
563
  const waiting = (t) => t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
562
564
  const shipped = waiting(loadTasks());
563
565
  if (!shipped.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
@@ -578,6 +580,7 @@ async function approveRepo(repo, opts = {}) {
578
580
  // repo's open tasks the changes finished (the run didn't tick them): they're
579
581
  // approved with the changes, as Approve does, so they don't go out again.
580
582
  async function approveChanges(repo, opts = {}) {
583
+ const pro = can("ship"); if (!pro.ok) return { error: pro.why, pro: true };
581
584
  if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return await approveRepo(repo, opts);
582
585
  const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
583
586
  const busy = stillWorking(repo, path); if (busy) return busy;
package/ui.mjs CHANGED
@@ -802,6 +802,7 @@ body.lq-liquid #panel-settings{max-width:780px;margin:0 auto;display:flex;flex-d
802
802
  body.lq-liquid .sset{border:1px solid rgba(255,255,255,.07);background:rgba(255,255,255,.03);border-radius:18px;padding:18px 20px}
803
803
  body.lq-liquid.lq-light .sset{border-color:rgba(21,26,33,.07);background:rgba(255,255,255,.55)}
804
804
  body.lq-liquid .sset.hidden,body.lq-liquid #panel-settings.hidden{display:none}
805
+ .licproj{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}.licproj .chip{display:inline-flex;align-items:center;gap:8px;padding:4px 6px 4px 12px;border:1px solid var(--line);border-radius:999px;font-size:13px}.licproj .chip button{padding:2px 8px;font-size:12px}
805
806
  .ssh{margin:0 0 10px;font:600 16px var(--sans);letter-spacing:-.01em;color:var(--bone);display:flex;align-items:baseline;gap:8px}
806
807
  .ssh .muted{font-weight:400;font-size:12.5px}
807
808
  body.lq-liquid .sset > label:not(.check):not(.swl){display:block;font:500 12.5px var(--sans);color:var(--faint);margin:12px 0 6px}
@@ -1168,6 +1169,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1168
1169
  </section>
1169
1170
  <section id="panel-settings" class="hidden">
1170
1171
  <div id="firststeps" class="sset hidden" aria-live="polite"></div>
1172
+ <div class="sset" id="licset"><h3 class="ssh">Symbiot Pro</h3><div id="licbox"><span class="muted">Checking…</span></div></div>
1171
1173
  <div class="sset"><h3 class="ssh">Setup</h3><div class="row"><span class="muted" style="flex:1">Walk through setup again: your AI, your work, your agent and your apps.</span><button class="ghost" id="rerunsetup">Run setup again</button></div></div>
1172
1174
  <div class="sset"><h3 class="ssh">Your AI</h3>
1173
1175
  <label>Which AI should Symbiot write with?</label>
@@ -1311,7 +1313,7 @@ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==
1311
1313
  $('panel-map').classList.toggle('hidden',!isMap);
1312
1314
  $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard){loadBoard();loadLinks();}
1313
1315
  $('panel-run').classList.toggle('hidden',!isRun);
1314
- $('panel-settings').classList.toggle('hidden',!isSet);if(isSet)loadFirstSteps();
1316
+ $('panel-settings').classList.toggle('hidden',!isSet);if(isSet){loadFirstSteps();licLoad();}
1315
1317
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1316
1318
  $('panel-drift').classList.toggle('hidden',!isDrift);
1317
1319
  $('panel-reports').classList.toggle('hidden',tab!=='reports');if(tab==='reports')loadReports();
@@ -1328,6 +1330,26 @@ lqTitle();if(isTasks&&TFILTER.repo)loadParked(lqTitle);}
1328
1330
  // takes you to its part, lit up. The block goes once all are done.
1329
1331
  var FSKIP='symbiot-skip-company';
1330
1332
  function fsSkipped(){try{return !!(window.localStorage&&window.localStorage.getItem(FSKIP));}catch(e){return false;}}
1333
+ // Symbiot Free and Pro (licence.mjs): where you stand, a key in or out, Free's 3 projects.
1334
+ function licDate(ms){try{return new Date(ms).toLocaleDateString(undefined,{day:'numeric',month:'long',year:'numeric'});}catch(e){return '';}}
1335
+ function licLoad(){api('/api/licence').then(licRender).catch(function(){});}
1336
+ function licRender(st,msg){var el=$('licbox');if(!el||!st)return;var h='';
1337
+ if(st.plan==='owner')h+="<p>Owner key"+(st.email?' for '+esc(st.email):'')+": everything, including Symbiot working on its own code.</p>";
1338
+ else if(st.plan==='pro'){h+="<p><b>Pro</b>"+(st.email?' for '+esc(st.email):'')+(st.expires?', until '+esc(licDate(st.expires)):'')+'.</p>';
1339
+ if(st.grace)h+="<p class='onbnote'>Your key has ended. Pro carries on for "+st.grace+" more day"+(st.grace===1?'':'s')+": renew it to keep it.</p>";
1340
+ else if(st.renewSoon)h+="<p class='muted'>It ends in "+st.renewSoon+" day"+(st.renewSoon===1?'':'s')+".</p>";}
1341
+ else if(st.plan==='trial')h+="<p><b>Pro trial</b>: "+st.trialDaysLeft+" day"+(st.trialDaysLeft===1?'':'s')+" left. After that Symbiot carries on as Free, unless you add a Pro key.</p>";
1342
+ else h+="<p><b>Free</b>: one agent at a time, up to "+st.free.projects+" projects and one inbox or chat."+(st.trialEnded?' Your Pro trial has ended.':'')+"</p>";
1343
+ if(st.keyProblem)h+="<p class='onbnote'>"+esc(st.keyProblem)+"</p>";
1344
+ if(st.plan==='free'||st.plan==='trial'){
1345
+ if(st.projects&&st.projects.length)h+="<p class='muted' style='margin-top:10px'>Free’s "+st.free.projects+" projects: the first ones Symbiot worked in.</p><div class='licproj'>"+st.projects.map(function(p,i){return "<span class='chip'>"+esc(p.name)+" <button class='back' data-i='"+i+"' title='Free this slot for another project'>free up</button></span>";}).join(' ')+"</div>";
1346
+ h+="<p style='margin-top:12px'>Pro: as many agents at once as you like, every project, every inbox and chat, the marketing lane and Approve-and-ship. "+esc(st.price)+". <a href='"+esc(st.upgrade)+"' target='_blank' rel='noopener'>Get Pro</a></p>";}
1347
+ if(st.plan!=='owner'){h+="<div class='row' style='margin-top:10px'>"+(st.plan==='pro'?"<button class='ghost' id='licclear'>Remove this key</button>":"<textarea id='lickey' rows='2' placeholder='Paste your Pro key (it starts SYM1-)' style='flex:1'></textarea><button class='act' id='licsave'>Add key</button>")+"</div>";}
1348
+ if(msg)h+="<p class='onbnote' style='margin-top:8px'>"+esc(msg)+"</p>";
1349
+ el.innerHTML=h;
1350
+ var sv=$('licsave');if(sv)sv.addEventListener('click',function(){var k=($('lickey').value||'').trim();if(!k)return;api('/api/licence/key',{key:k}).then(function(r){if(r&&r.error){licRender(st,r.error);return;}licRender(r,'Thanks: Symbiot Pro is on.');}).catch(function(){});});
1351
+ var cl=$('licclear');if(cl)cl.addEventListener('click',function(){api('/api/licence/clear',{}).then(function(r){licRender(r);}).catch(function(){});});
1352
+ el.querySelectorAll('.licproj button[data-i]').forEach(function(b){b.addEventListener('click',function(){var p=st.projects[+b.getAttribute('data-i')];if(!p)return;api('/api/licence/project/free',{path:p.path}).then(function(r){licRender(r);}).catch(function(){});});});}
1331
1353
  function loadFirstSteps(){api('/api/firststeps').then(function(f){var el=$('firststeps');if(!el||!f||!f.steps)return;
1332
1354
  var st=f.steps.map(function(s){return s.id==='company'&&!s.done&&fsSkipped()?Object.assign({},s,{done:true,skipped:true}):s;}),next=st.filter(function(s){return !s.done;})[0];
1333
1355
  if(!next){el.innerHTML='';el.classList.add('hidden');return;}el.classList.remove('hidden');