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 +6 -0
- package/LICENSE +98 -21
- package/README.md +26 -0
- package/agents.mjs +23 -2
- package/claudesetup.mjs +4 -1
- package/guard.mjs +8 -1
- package/licence.mjs +144 -0
- package/links.mjs +7 -0
- package/package.json +6 -5
- package/server.mjs +13 -4
- package/tasks.mjs +3 -0
- package/ui.mjs +23 -1
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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) {
|
|
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.
|
|
4
|
-
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice
|
|
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": "
|
|
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');
|