agentbox-app 0.1.7 → 0.1.12
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/README.md +220 -58
- package/bin/agentbox.mjs +18 -3
- package/briefs/message-rules.md +24 -3
- package/briefs/worker.md +63 -49
- package/briefs/writing-rules.md +90 -131
- package/main/account-discovery.mjs +21 -20
- package/main/account-tooling.mjs +11 -11
- package/main/agent-activity.mjs +45 -5
- package/main/agent-schedule.mjs +14 -14
- package/main/agent-sessions.mjs +94 -10
- package/main/agent-updates.mjs +116 -0
- package/main/agents.mjs +52 -46
- package/main/analytics.mjs +27 -0
- package/main/approval-prompt-server.mjs +2 -2
- package/main/approvals.mjs +14 -5
- package/main/artifact-path.mjs +11 -0
- package/main/claude-bin.mjs +32 -5
- package/main/claude-input.mjs +45 -3
- package/main/claude-plan.mjs +25 -13
- package/main/code-change.mjs +3 -3
- package/main/codex-account.mjs +1 -1
- package/main/codex-bin.mjs +18 -0
- package/main/codex-launch-env.mjs +31 -0
- package/main/codex-memory-gate.mjs +260 -0
- package/main/codex-models.mjs +12 -7
- package/main/codex-threads.mjs +46 -23
- package/main/codex-usage-file.mjs +7 -7
- package/main/codex-usage.mjs +4 -4
- package/main/codex-watch.mjs +12 -12
- package/main/config.mjs +82 -17
- package/main/demo.mjs +17 -14
- package/main/dev-window.mjs +9 -9
- package/main/doc-scheme.mjs +2 -0
- package/main/engine-setup.mjs +204 -0
- package/main/feedback.mjs +55 -0
- package/main/folder-pictures.mjs +48 -0
- package/main/frame-scrollbars.mjs +90 -0
- package/main/fresh-user.mjs +7 -4
- package/main/git-change-offthread.mjs +70 -0
- package/main/git-change-thread.mjs +11 -0
- package/main/git-change.mjs +9 -3
- package/main/img-scheme.mjs +70 -1
- package/main/instruction-settings.mjs +134 -60
- package/main/ipc.mjs +335 -42
- package/main/leftovers.mjs +518 -0
- package/main/live-replies.mjs +43 -5
- package/main/machine.mjs +48 -1
- package/main/main.mjs +138 -15
- package/main/memory-gate-history.mjs +254 -0
- package/main/memory-gate-server.mjs +461 -0
- package/main/memory-gate.mjs +206 -0
- package/main/message-priority.mjs +87 -0
- package/main/notify.mjs +1 -1
- package/main/rail-note.mjs +2 -2
- package/main/refresh-agent-models.mjs +21 -0
- package/main/repeats.mjs +4 -4
- package/main/row-label.mjs +115 -51
- package/main/runs-on.mjs +93 -0
- package/main/serve.mjs +77 -5
- package/main/settings.mjs +245 -26
- package/main/ship-queue.mjs +279 -0
- package/main/sign-in-files.mjs +39 -0
- package/main/source-updater.mjs +298 -0
- package/main/store/home.mjs +84 -7
- package/main/store/project.mjs +15 -19
- package/main/store/work-items.mjs +150 -6
- package/main/store.mjs +245 -13
- package/main/supervisor.mjs +1634 -417
- package/main/task-commands.mjs +8 -10
- package/main/task-folders.mjs +94 -16
- package/main/task-remote-control.mjs +3 -3
- package/main/task-terminals.mjs +14 -2
- package/main/team/index.mjs +440 -0
- package/main/team/memory-cloud.mjs +247 -0
- package/main/team/projects.mjs +162 -0
- package/main/team/session.mjs +225 -0
- package/main/team/sign-in.mjs +106 -0
- package/main/team/supabase-backend.mjs +231 -0
- package/main/team/sync.mjs +163 -0
- package/main/updater.mjs +15 -5
- package/main/write-before-reload.mjs +50 -0
- package/mcp/core/page.mjs +10 -12
- package/mcp/core/work.mjs +123 -26
- package/mcp/tools.mjs +13 -6
- package/package.json +21 -9
- package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
- package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
- package/renderer/dist/index.html +2 -2
- package/scripts/after-install.mjs +14 -0
- package/scripts/approval-server.sh +7 -2
- package/scripts/memory-gate-hook.sh +59 -0
- package/shared/agent-import.mjs +39 -16
- package/shared/agents.d.mts +26 -8
- package/shared/agents.mjs +100 -108
- package/shared/analytics-events.mjs +19 -2
- package/shared/answers.mjs +12 -0
- package/shared/bridge-map.d.mts +12 -0
- package/shared/bridge-map.mjs +38 -1
- package/shared/claude-commands.generated.mjs +2 -2
- package/shared/claude-models.generated.mjs +6 -6
- package/shared/claude-usage.mjs +6 -6
- package/shared/codex-import.mjs +7 -7
- package/shared/codex-modes.d.mts +50 -0
- package/shared/codex-modes.mjs +1 -1
- package/shared/codex-usage.mjs +7 -8
- package/shared/demo-world.mjs +6 -6
- package/shared/engines.mjs +27 -10
- package/shared/feedback.d.mts +14 -0
- package/shared/feedback.mjs +44 -0
- package/shared/first-run-examples.mjs +4 -4
- package/shared/first-run-practice.d.mts +16 -0
- package/shared/first-run-practice.mjs +176 -70
- package/shared/fresh-user-home.mjs +34 -4
- package/shared/instruction-defaults.json +2 -2
- package/shared/product-name.mjs +4 -2
- package/shared/project-folder-check.mjs +9 -3
- package/shared/rank.d.mts +1 -0
- package/shared/rank.mjs +22 -0
- package/shared/referenced-files.mjs +1 -1
- package/shared/runs-on.d.mts +20 -0
- package/shared/runs-on.mjs +79 -0
- package/shared/schemes.d.mts +11 -0
- package/shared/schemes.mjs +1 -1
- package/shared/shortcuts.d.mts +41 -0
- package/shared/shortcuts.mjs +236 -0
- package/shared/spawn-trouble.d.mts +2 -0
- package/shared/spawn-trouble.mjs +40 -19
- package/shared/team-rules.d.mts +17 -0
- package/shared/team-rules.mjs +230 -0
- package/shared/team-status.d.mts +11 -0
- package/shared/team-status.mjs +90 -0
- package/shared/terminal-links.d.mts +2 -0
- package/shared/terminal-links.mjs +55 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +16 -0
- package/shared/thread-cards.d.mts +19 -0
- package/shared/thread-cards.mjs +254 -0
- package/shared/usage.d.mts +1 -1
- package/shared/usage.mjs +6 -7
- package/shared/work-items.mjs +258 -28
- package/shared/work-lines.mjs +393 -16
- package/worker-permissions.json +1 -1
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
- package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/emulator/audio-rules.mjs +0 -74
- package/renderer/dist/emulator/data/compression/README.md +0 -11
- package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
- package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
- package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +0 -22
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +0 -65
- package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
- package/renderer/dist/emulator/data/emulator.css +0 -1614
- package/renderer/dist/emulator/data/loader.js +0 -170
- package/renderer/dist/emulator/data/localization/README.md +0 -63
- package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
- package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
- package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
- package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
- package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
- package/renderer/dist/emulator/data/localization/en-US.json +0 -339
- package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
- package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
- package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
- package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
- package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
- package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
- package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
- package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
- package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
- package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
- package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
- package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
- package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
- package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
- package/renderer/dist/emulator/data/src/GameManager.js +0 -464
- package/renderer/dist/emulator/data/src/compression.js +0 -142
- package/renderer/dist/emulator/data/src/emulator.js +0 -6158
- package/renderer/dist/emulator/data/src/gamepad.js +0 -167
- package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
- package/renderer/dist/emulator/data/src/shaders.js +0 -236
- package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
- package/renderer/dist/emulator/data/src/storage.js +0 -125
- package/renderer/dist/emulator/data/version.json +0 -5
- package/renderer/dist/emulator/host.js +0 -299
- package/renderer/dist/emulator/index.html +0 -28
- package/renderer/dist/emulator/provenance.json +0 -6
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// THE SHARED CLOUD, IN MEMORY, WITH THE SAME RULES AS THE REAL ONE.
|
|
2
|
+
//
|
|
3
|
+
// The team version talks to Supabase through one small set of calls (see
|
|
4
|
+
// supabase-backend.mjs). This is the same set of calls over plain objects, so
|
|
5
|
+
// the sync engine and the app can be tested without a network, and so two
|
|
6
|
+
// stores in one test can be two teammates. Every rule the database's row level
|
|
7
|
+
// security enforces is enforced here too, and tests/team-cloud-backends-agree
|
|
8
|
+
// runs one scenario against both to keep them honest.
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { asPulled } from '../../shared/team-rules.mjs';
|
|
12
|
+
import { cleanStatus } from '../../shared/team-status.mjs';
|
|
13
|
+
|
|
14
|
+
export function createMemoryCloud() {
|
|
15
|
+
return {
|
|
16
|
+
people: new Map(), // id -> { id, email, name, avatarUrl }
|
|
17
|
+
teams: new Map(), // id -> { id, name, createdBy }
|
|
18
|
+
members: [], // { teamId, personId, role }
|
|
19
|
+
invites: [], // { teamId, email }
|
|
20
|
+
projects: new Map(), // id -> { id, teamId, name, visibility, createdBy }
|
|
21
|
+
projectPeople: [], // { projectId, personId }
|
|
22
|
+
lines: [], // { seq, projectId, uid, byPerson, body, createdAt }
|
|
23
|
+
cards: new Map(), // `${personId}:${threadId}` -> a thread card, as shared/thread-cards.mjs makes them
|
|
24
|
+
seq: 0,
|
|
25
|
+
teamsMade: 0, // the order teams were made in, which is their age
|
|
26
|
+
now: () => Date.now(), // the server's clock
|
|
27
|
+
events: new EventEmitter(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A sign-in, the way Supabase's trigger turns one into a person. Google
|
|
32
|
+
// confirms the email; `confirmed: false` is somebody who never proved theirs.
|
|
33
|
+
export function signUpMemory(cloud, { id = crypto.randomUUID(), email, name, avatarUrl = null, confirmed = true }) {
|
|
34
|
+
cloud.people.set(id, { id, email, name: name || String(email).split('@')[0], avatarUrl, status: null });
|
|
35
|
+
if (!confirmed) (cloud.unconfirmed ??= new Set()).add(id);
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function memoryBackend(cloud, personId) {
|
|
40
|
+
const myTeams = () => cloud.members.filter((m) => m.personId === personId).map((m) => m.teamId)
|
|
41
|
+
.sort((a, b) => (cloud.teams.get(a)?.made ?? 0) - (cloud.teams.get(b)?.made ?? 0));
|
|
42
|
+
// The email the sign-in confirmed, never one a person could have typed.
|
|
43
|
+
const confirmedEmail = () => {
|
|
44
|
+
const me = cloud.people.get(personId);
|
|
45
|
+
return me && !cloud.unconfirmed?.has(personId) ? String(me.email).toLowerCase() : null;
|
|
46
|
+
};
|
|
47
|
+
const owns = (teamId) => cloud.members.some((m) => m.teamId === teamId && m.personId === personId && m.role === 'owner');
|
|
48
|
+
const canSee = (projectId) => {
|
|
49
|
+
const p = cloud.projects.get(projectId);
|
|
50
|
+
if (!p || !myTeams().includes(p.teamId)) return false;
|
|
51
|
+
return p.visibility === 'team' || p.createdBy === personId
|
|
52
|
+
|| cloud.projectPeople.some((pp) => pp.projectId === projectId && pp.personId === personId);
|
|
53
|
+
};
|
|
54
|
+
const projectOut = (p) => ({
|
|
55
|
+
id: p.id, teamId: p.teamId, name: p.name, visibility: p.visibility, createdBy: p.createdBy, direct: !!p.direct,
|
|
56
|
+
people: cloud.projectPeople.filter((pp) => pp.projectId === p.id).map((pp) => pp.personId),
|
|
57
|
+
});
|
|
58
|
+
const refuse = (what) => { throw new Error(`not allowed: ${what}`); };
|
|
59
|
+
const personOut = (p) => p && ({ id: p.id, email: p.email, name: p.name, avatarUrl: p.avatarUrl ?? null, status: p.status ?? null });
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
kind: 'memory',
|
|
63
|
+
personId,
|
|
64
|
+
|
|
65
|
+
async me() {
|
|
66
|
+
const me = cloud.people.get(personId);
|
|
67
|
+
return me ? personOut(me) : null;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// A line you wrote about yourself. Only ever your own: the database's
|
|
71
|
+
// people_update_self says the same thing, and the personId argument is
|
|
72
|
+
// here so a test can try to write somebody else's and be refused.
|
|
73
|
+
async setStatus({ text, until = null, personId: who = personId } = {}) {
|
|
74
|
+
if (who !== personId) refuse('write somebody else\'s status');
|
|
75
|
+
const me = cloud.people.get(personId);
|
|
76
|
+
if (!me) refuse('set a status before signing in');
|
|
77
|
+
const clean = cleanStatus(text);
|
|
78
|
+
me.status = clean ? { text: clean, until: until ?? null } : null;
|
|
79
|
+
cloud.events.emit('people', {});
|
|
80
|
+
return me.status;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async pendingInvites() {
|
|
84
|
+
const email = confirmedEmail();
|
|
85
|
+
if (!email) return [];
|
|
86
|
+
return cloud.invites
|
|
87
|
+
.filter((inv) => inv.email.toLowerCase() === email && !myTeams().includes(inv.teamId))
|
|
88
|
+
.map((inv) => ({ teamId: inv.teamId, teamName: cloud.teams.get(inv.teamId)?.name ?? '', invitedBy: inv.invitedBy ?? null, invitedByName: cloud.people.get(inv.invitedBy)?.name ?? null }));
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
// One team, on a yes, and the invite is used up.
|
|
92
|
+
async acceptInvite(teamId) {
|
|
93
|
+
const email = confirmedEmail();
|
|
94
|
+
if (!email) refuse('join a team without a confirmed email');
|
|
95
|
+
const at = cloud.invites.findIndex((inv) => inv.teamId === teamId && inv.email.toLowerCase() === email);
|
|
96
|
+
if (at < 0) refuse('join a team that did not invite you');
|
|
97
|
+
cloud.invites.splice(at, 1);
|
|
98
|
+
if (!cloud.members.some((m) => m.teamId === teamId && m.personId === personId)) cloud.members.push({ teamId, personId, role: 'member' });
|
|
99
|
+
return teamId;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async myTeams() {
|
|
103
|
+
return myTeams().map((id) => cloud.teams.get(id)).filter(Boolean).map((t) => ({ id: t.id, name: t.name }));
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async myTeam() {
|
|
107
|
+
return (await this.myTeams())[0] ?? null;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async createTeam(name) {
|
|
111
|
+
const id = crypto.randomUUID();
|
|
112
|
+
cloud.teamsMade += 1;
|
|
113
|
+
cloud.teams.set(id, { id, name, createdBy: personId, made: cloud.teamsMade });
|
|
114
|
+
cloud.members.push({ teamId: id, personId, role: 'owner' });
|
|
115
|
+
return { id, name };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async teamPeople(teamId) {
|
|
119
|
+
if (!myTeams().includes(teamId)) return [];
|
|
120
|
+
const rows = cloud.members.filter((m) => m.teamId === teamId);
|
|
121
|
+
return rows.map((m) => ({ ...personOut(cloud.people.get(m.personId)), role: m.role })).filter((p) => p.id);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// A line you wrote about yourself. Only ever your own: the database's
|
|
125
|
+
// people_update_self says the same thing, and the personId argument is
|
|
126
|
+
// here so a test can try to write somebody else's and be refused.
|
|
127
|
+
async setStatus({ text, until = null, personId: who = personId } = {}) {
|
|
128
|
+
if (who !== personId) refuse('write somebody else\'s status');
|
|
129
|
+
const me = cloud.people.get(personId);
|
|
130
|
+
if (!me) refuse('set a status before signing in');
|
|
131
|
+
const clean = cleanStatus(text);
|
|
132
|
+
me.status = clean ? { text: clean, until: until ?? null } : null;
|
|
133
|
+
cloud.events.emit('people', {});
|
|
134
|
+
return me.status;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// TEAM SETTINGS (2026-10-01), the same rules as 20261001000600_team_settings.sql.
|
|
138
|
+
async renameTeam(teamId, name) {
|
|
139
|
+
if (!owns(teamId)) refuse('rename a team you do not own');
|
|
140
|
+
const t = cloud.teams.get(teamId);
|
|
141
|
+
t.name = String(name).trim();
|
|
142
|
+
return { id: t.id, name: t.name };
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async removeMember(teamId, who) {
|
|
146
|
+
if (who !== personId && !owns(teamId)) refuse('remove somebody from a team you do not own');
|
|
147
|
+
cloud.members = cloud.members.filter((m) => !(m.teamId === teamId && m.personId === who));
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async listInvites(teamId) {
|
|
151
|
+
if (!myTeams().includes(teamId)) return [];
|
|
152
|
+
return cloud.invites.filter((i) => i.teamId === teamId).map((i) => ({ email: i.email, invitedBy: i.invitedBy ?? null }));
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async cancelInvite(teamId, email) {
|
|
156
|
+
const clean = String(email).trim().toLowerCase();
|
|
157
|
+
const inv = cloud.invites.find((i) => i.teamId === teamId && i.email.toLowerCase() === clean);
|
|
158
|
+
if (!inv) return;
|
|
159
|
+
if (!owns(teamId) && inv.invitedBy !== personId) refuse('cancel an invite you did not send');
|
|
160
|
+
cloud.invites = cloud.invites.filter((i) => i !== inv);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async invite(teamId, email) {
|
|
164
|
+
if (!myTeams().includes(teamId)) refuse('invite to a team you are not on');
|
|
165
|
+
const clean = String(email).trim();
|
|
166
|
+
if (!cloud.invites.some((i) => i.teamId === teamId && i.email.toLowerCase() === clean.toLowerCase())) {
|
|
167
|
+
cloud.invites.push({ teamId, email: clean, invitedBy: personId });
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async listProjects() {
|
|
172
|
+
return [...cloud.projects.values()].filter((p) => canSee(p.id)).map(projectOut);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async shareProject({ id, teamId, name, visibility = 'team', people = [], direct = false }) {
|
|
176
|
+
const existing = cloud.projects.get(id);
|
|
177
|
+
if (existing && existing.createdBy !== personId) refuse('change a project somebody else shared');
|
|
178
|
+
if (!myTeams().includes(teamId)) refuse('share into a team you are not on');
|
|
179
|
+
// A test can make the cloud refuse a share, to prove a failed start leaves nothing.
|
|
180
|
+
if (cloud.refuseShares) refuse(cloud.refuseShares);
|
|
181
|
+
// A message thread is its maker and up to eleven others, as the database
|
|
182
|
+
// insists (migration 20261001000700_group_messages.sql).
|
|
183
|
+
if (direct && (visibility !== 'people' || new Set(people.filter((p) => p !== personId)).size > 11)) refuse('put more than twelve people on a message thread');
|
|
184
|
+
cloud.projects.set(id, { id, teamId, name, visibility, createdBy: personId, direct: !!direct });
|
|
185
|
+
cloud.projectPeople = cloud.projectPeople.filter((pp) => pp.projectId !== id);
|
|
186
|
+
for (const person of new Set(people)) cloud.projectPeople.push({ projectId: id, personId: person });
|
|
187
|
+
return projectOut(cloud.projects.get(id));
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async pushLines(projectId, lines) {
|
|
191
|
+
if (!canSee(projectId)) refuse('write to a project you cannot see');
|
|
192
|
+
let added = 0;
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
if (!line?.uid || line.by !== personId) refuse('write a line as somebody else');
|
|
195
|
+
if (cloud.lines.some((l) => l.uid === line.uid)) continue;
|
|
196
|
+
cloud.seq += 1;
|
|
197
|
+
cloud.lines.push({ seq: cloud.seq, projectId, uid: line.uid, byPerson: personId, body: structuredClone(line), createdAt: cloud.now() });
|
|
198
|
+
added += 1;
|
|
199
|
+
}
|
|
200
|
+
if (added) cloud.events.emit('lines', { projectId });
|
|
201
|
+
return added;
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async pullLines(projectId, afterSeq = 0, limit = 500) {
|
|
205
|
+
if (!canSee(projectId)) return [];
|
|
206
|
+
return cloud.lines.filter((l) => l.projectId === projectId && l.seq > afterSeq)
|
|
207
|
+
.slice(0, limit).map((l) => ({ seq: l.seq, line: asPulled(structuredClone(l.body), { by: l.byPerson, at: l.createdAt, me: personId }) }));
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async putCards(teamId, cards) {
|
|
211
|
+
if (!myTeams().includes(teamId)) throw new Error('not on that team');
|
|
212
|
+
for (const key of [...cloud.cards.keys()]) if (key.startsWith(`${personId}:`)) cloud.cards.delete(key);
|
|
213
|
+
// A private card never goes up at all, whatever the Mac handed over, as
|
|
214
|
+
// supabase-backend does; the database refuses words on one besides.
|
|
215
|
+
for (const c of cards) {
|
|
216
|
+
if (c.visible !== true) continue;
|
|
217
|
+
// NO LIST IS THE WHOLE TEAM, and an empty one is refused rather than
|
|
218
|
+
// stored, as the table's own check does: it would have to mean both
|
|
219
|
+
// everyone and nobody (20261001000800_chosen_people.sql).
|
|
220
|
+
const people = c.people == null ? null : [...new Set(c.people.filter((p) => typeof p === 'string' && p))];
|
|
221
|
+
if (people && !people.length) refuse('share a thread with nobody');
|
|
222
|
+
cloud.cards.set(`${personId}:${c.threadId}`, { ...structuredClone(c), people, personId, teamId });
|
|
223
|
+
}
|
|
224
|
+
cloud.events.emit('cards', {});
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
// A CARD REACHES WHOEVER IT NAMES, and everyone on the team when it names
|
|
228
|
+
// nobody (w-41ff964775). The hosted database says the same thing in its
|
|
229
|
+
// own row level security (20261001000800_chosen_people.sql), which is what
|
|
230
|
+
// actually stops a teammate's app asking for a card it was not sent.
|
|
231
|
+
async listCards() {
|
|
232
|
+
const teams = new Set(myTeams());
|
|
233
|
+
return [...cloud.cards.values()]
|
|
234
|
+
.filter((c) => teams.has(c.teamId))
|
|
235
|
+
.filter((c) => c.people == null || c.personId === personId || c.people.includes(personId))
|
|
236
|
+
.map(({ teamId, ...c }) => structuredClone(c));
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
subscribe(onChange) {
|
|
240
|
+
const fire = () => onChange();
|
|
241
|
+
cloud.events.on('lines', fire);
|
|
242
|
+
cloud.events.on('cards', fire);
|
|
243
|
+
cloud.events.on('people', fire);
|
|
244
|
+
return () => { cloud.events.off('lines', fire); cloud.events.off('cards', fire); cloud.events.off('people', fire); };
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// A PROJECT ON THIS MAC, SEEN AS SHARED OR PRIVATE.
|
|
2
|
+
//
|
|
3
|
+
// A shared project is an ordinary local project whose project.json carries a
|
|
4
|
+
// `team` record: the project's id in the cloud, the team, and who can see it.
|
|
5
|
+
// That id is the same on every teammate's Mac; the folder, its name on disk and
|
|
6
|
+
// the code it points at are each person's own. A project without `team` is
|
|
7
|
+
// private, and private projects never leave the Mac (only the title-free
|
|
8
|
+
// activity below does).
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
const PROJECT_FILE = 'project.json';
|
|
14
|
+
|
|
15
|
+
function readProject(dir) {
|
|
16
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, PROJECT_FILE), 'utf8')); } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeProject(dir, project) {
|
|
20
|
+
const file = path.join(dir, PROJECT_FILE);
|
|
21
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
22
|
+
fs.writeFileSync(tmp, JSON.stringify(project, null, 2));
|
|
23
|
+
fs.renameSync(tmp, file);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The `team` record, cleaned, or null for a private project.
|
|
27
|
+
export function teamOf(project) {
|
|
28
|
+
const t = project?.team;
|
|
29
|
+
if (!t || typeof t !== 'object' || typeof t.projectId !== 'string' || !t.projectId) return null;
|
|
30
|
+
return {
|
|
31
|
+
projectId: t.projectId,
|
|
32
|
+
teamId: typeof t.teamId === 'string' ? t.teamId : null,
|
|
33
|
+
visibility: t.visibility === 'people' ? 'people' : 'team',
|
|
34
|
+
people: Array.isArray(t.people) ? t.people.filter((p) => typeof p === 'string') : [],
|
|
35
|
+
// Who shared it. Runs the rows written before anyone signed in
|
|
36
|
+
// (shared/team-rules.mjs runnerOf).
|
|
37
|
+
sharedBy: typeof t.sharedBy === 'string' ? t.sharedBy : null,
|
|
38
|
+
// A MESSAGE THREAD between two people (approved 2026-10-01): a shared
|
|
39
|
+
// record the two of them only can see, kept out of every project list.
|
|
40
|
+
direct: t.direct === true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readTeam(dir) {
|
|
45
|
+
return teamOf(readProject(dir));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Every shared project on this Mac, as sync wants them.
|
|
49
|
+
export function listSharedProjects(products) {
|
|
50
|
+
return products
|
|
51
|
+
.filter((p) => p.team?.projectId)
|
|
52
|
+
.map((p) => ({ projectId: p.team.projectId, dir: p.dir, name: p.name, slug: p.slug, direct: p.team.direct === true }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// YOUR PERSONAL PROJECT (w-b989839656): one per person, made when they sign
|
|
56
|
+
// in, where a thread with no visibility of its own is Only you
|
|
57
|
+
// (shared/thread-cards.mjs visibilityOf). It says so about itself in
|
|
58
|
+
// project.json rather than by its name, because "personal" is a name somebody
|
|
59
|
+
// may already have given a project of their own, and that one must stay as it
|
|
60
|
+
// is. Never shared, never remade once it exists, archived or not.
|
|
61
|
+
export const PERSONAL_FLAG = 'personal';
|
|
62
|
+
// CALLED MY WORKSPACE, in title case: the founder's pick on 2026-10-04. It is
|
|
63
|
+
// a workspace of your own rather than a label for private things, and every
|
|
64
|
+
// word of a project's name is capitalised.
|
|
65
|
+
const PERSONAL_NAME = 'My Workspace';
|
|
66
|
+
const PERSONAL_SLUG = 'my-workspace';
|
|
67
|
+
// What it was called when it first shipped (9e2a626). One still carrying it
|
|
68
|
+
// takes the new name; a name the person gave it is theirs.
|
|
69
|
+
const FIRST_NAME = 'Personal';
|
|
70
|
+
|
|
71
|
+
export function ensurePersonalProject(accountRoot) {
|
|
72
|
+
let entries = [];
|
|
73
|
+
try { entries = fs.readdirSync(accountRoot); } catch { entries = []; }
|
|
74
|
+
for (const slug of entries.sort()) {
|
|
75
|
+
const dir = path.join(accountRoot, slug);
|
|
76
|
+
const project = readProject(dir);
|
|
77
|
+
if (project?.[PERSONAL_FLAG] !== true) continue;
|
|
78
|
+
if (project.name === FIRST_NAME) writeProject(dir, { ...project, name: PERSONAL_NAME });
|
|
79
|
+
return { slug, made: false };
|
|
80
|
+
}
|
|
81
|
+
let slug = PERSONAL_SLUG;
|
|
82
|
+
for (let n = 2; fs.existsSync(path.join(accountRoot, slug)); n += 1) slug = `${PERSONAL_SLUG}-${n}`;
|
|
83
|
+
const dir = path.join(accountRoot, slug);
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
writeProject(dir, {
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
id: slug,
|
|
88
|
+
name: PERSONAL_NAME,
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
[PERSONAL_FLAG]: true,
|
|
91
|
+
});
|
|
92
|
+
return { slug, made: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Mark a local project shared. Its cloud id is minted the first time and kept
|
|
96
|
+
// for good, so sharing, unsharing to specific people and sharing again is one
|
|
97
|
+
// project in the cloud, not three.
|
|
98
|
+
export function markShared(dir, { teamId, visibility = 'team', people = [], sharedBy = null }) {
|
|
99
|
+
const project = readProject(dir);
|
|
100
|
+
if (!project) throw new Error(`no project.json in ${dir}`);
|
|
101
|
+
// Sharing it would put every line of it in the cloud, which is the one thing
|
|
102
|
+
// this project exists to make hard. A thread in it is shared one at a time.
|
|
103
|
+
if (project[PERSONAL_FLAG] === true) throw new Error(`${PERSONAL_NAME} stays on this computer. Share a thread in it instead.`);
|
|
104
|
+
const projectId = project.team?.projectId || crypto.randomUUID();
|
|
105
|
+
project.team = {
|
|
106
|
+
projectId, teamId, visibility: visibility === 'people' ? 'people' : 'team', people: [...new Set(people)],
|
|
107
|
+
sharedBy: project.team?.sharedBy || sharedBy,
|
|
108
|
+
};
|
|
109
|
+
writeProject(dir, project);
|
|
110
|
+
return teamOf(project);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A project a teammate shared, made into a project here. Its folder is named
|
|
114
|
+
// from the project's name, and a name already taken on this Mac gets -2, -3.
|
|
115
|
+
export function joinSharedProject(accountRoot, cloudProject) {
|
|
116
|
+
const base = cloudProject.direct ? `direct-${String(cloudProject.id).slice(0, 8)}` : String(cloudProject.name || 'shared').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'shared';
|
|
117
|
+
let slug = base;
|
|
118
|
+
for (let n = 2; fs.existsSync(path.join(accountRoot, slug)); n += 1) {
|
|
119
|
+
// The same project joined before (a folder that already carries its id) is
|
|
120
|
+
// not joined twice.
|
|
121
|
+
if (readTeam(path.join(accountRoot, slug))?.projectId === cloudProject.id) {
|
|
122
|
+
return { projectId: cloudProject.id, dir: path.join(accountRoot, slug), name: cloudProject.name, slug, direct: readTeam(path.join(accountRoot, slug))?.direct === true };
|
|
123
|
+
}
|
|
124
|
+
slug = `${base}-${n}`;
|
|
125
|
+
}
|
|
126
|
+
const dir = path.join(accountRoot, slug);
|
|
127
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
128
|
+
writeProject(dir, {
|
|
129
|
+
schemaVersion: 1,
|
|
130
|
+
id: slug,
|
|
131
|
+
name: cloudProject.name,
|
|
132
|
+
createdAt: new Date().toISOString(),
|
|
133
|
+
team: {
|
|
134
|
+
projectId: cloudProject.id,
|
|
135
|
+
teamId: cloudProject.teamId ?? null,
|
|
136
|
+
visibility: cloudProject.visibility === 'people' ? 'people' : 'team',
|
|
137
|
+
people: cloudProject.people ?? [],
|
|
138
|
+
sharedBy: cloudProject.createdBy ?? null,
|
|
139
|
+
...(cloudProject.direct ? { direct: true } : {}),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return { projectId: cloudProject.id, dir, name: cloudProject.name, slug, direct: cloudProject.direct === true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// THE RECORD A MESSAGE BETWEEN TWO PEOPLE LIVES IN, made on the sender's Mac
|
|
146
|
+
// the first time they write to that person. Its folder is named for nobody, so
|
|
147
|
+
// it can never collide with a project, and both people are on it from the
|
|
148
|
+
// start; the other Mac joins it like any shared record.
|
|
149
|
+
export function makeDirect(accountRoot, { teamId, me, other, others = other ? [other] : [] }) {
|
|
150
|
+
const projectId = crypto.randomUUID();
|
|
151
|
+
const slug = `direct-${projectId.slice(0, 8)}`;
|
|
152
|
+
const dir = path.join(accountRoot, slug);
|
|
153
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
154
|
+
writeProject(dir, {
|
|
155
|
+
schemaVersion: 1,
|
|
156
|
+
id: slug,
|
|
157
|
+
name: 'Direct',
|
|
158
|
+
createdAt: new Date().toISOString(),
|
|
159
|
+
team: { projectId, teamId, visibility: 'people', people: [me, ...others], sharedBy: me, direct: true },
|
|
160
|
+
});
|
|
161
|
+
return { projectId, dir, slug };
|
|
162
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// THE SIGNED-IN SESSION, KEPT ON THIS MAC.
|
|
2
|
+
//
|
|
3
|
+
// A supabase-js client whose session lives in one file beside the store,
|
|
4
|
+
// encrypted with the Mac's keychain-backed safeStorage when the app has it, so
|
|
5
|
+
// a person stays signed in across restarts and nothing readable sits on disk.
|
|
6
|
+
// Each store root has its own file, which is what lets two copies of the app
|
|
7
|
+
// on one Mac be two different teammates.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { nameSlug } from '../../shared/product-name.mjs';
|
|
12
|
+
import { runningAsAFreshUser } from '../../shared/fresh-user-home.mjs';
|
|
13
|
+
import { supabaseBackend } from './supabase-backend.mjs';
|
|
14
|
+
import { signInWithGoogle, cancelGoogleSignIn } from './sign-in.mjs';
|
|
15
|
+
|
|
16
|
+
// A BUILD SOMEONE INSTALLED IGNORES THE TWO TEST SWITCHES BELOW. Found by
|
|
17
|
+
// review 2026-10-01: AGENTBOX_TEAM_CONFIG and AGENTBOX_TEAM_TEST_LOGIN were
|
|
18
|
+
// honoured in the packaged app, so anything that could set an environment
|
|
19
|
+
// variable for it could point it at another server or sign it in as somebody
|
|
20
|
+
// else. Electron sets defaultApp only when run from a checkout.
|
|
21
|
+
export const isPackagedElectron = () => !!process.versions?.electron && !process.defaultApp;
|
|
22
|
+
|
|
23
|
+
// THE TEAM KEY BELONGS TO THE MAC, NOT TO THE FOLDER THE APP RUNS FROM.
|
|
24
|
+
//
|
|
25
|
+
// Asked for 2026-10-04 (w-2fce569057): "we want to run the team version and i
|
|
26
|
+
// need to see my team!" A key per folder is the wrong unit, and the cost of it
|
|
27
|
+
// was a whole hour. Measured that day: the only Agentbox running was a copy out
|
|
28
|
+
// of a task folder with no key, the app in the Dock had none either, and the one
|
|
29
|
+
// folder on the machine that had a key was not running. So no window open could
|
|
30
|
+
// show a teammate, and a message to one looked lost when it had arrived fine.
|
|
31
|
+
//
|
|
32
|
+
// A person has one team. Their Mac holds its key once, at
|
|
33
|
+
// ~/.<app>/team.config.json, and whichever copy they open is the team version:
|
|
34
|
+
// the checkout, a worktree, or the app in the Dock.
|
|
35
|
+
//
|
|
36
|
+
// IT IS READ OFF THE REAL HOME DIRECTORY AND NEVER OFF AGENTBOX_HOME. A review
|
|
37
|
+
// on 2026-10-01 found an installed build honouring AGENTBOX_TEAM_CONFIG, so
|
|
38
|
+
// anything that could set an environment variable could point the app at
|
|
39
|
+
// another server. appHome() reads AGENTBOX_HOME, so using it here would reopen
|
|
40
|
+
// that door; os.homedir() cannot be talked into lying.
|
|
41
|
+
export function teamConfigOnThisMac(home = os.homedir()) {
|
|
42
|
+
return path.join(home, `.${nameSlug}`, 'team.config.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The hosted project's address and public key. Row level security, not secrecy,
|
|
46
|
+
// is what protects the data, but the file is kept out of the repository (it is
|
|
47
|
+
// ignored), so a checkout with no key on its Mac runs as the single-person app;
|
|
48
|
+
// cloud/team.config.example.json shows its shape.
|
|
49
|
+
//
|
|
50
|
+
// THE FOLDER'S OWN KEY WINS when it has one, so a worktree set up against
|
|
51
|
+
// another team is never quietly moved onto this Mac's. AGENTBOX_TEAM_CONFIG
|
|
52
|
+
// points at another file from a checkout only, and is first because that is
|
|
53
|
+
// what the tests run on.
|
|
54
|
+
//
|
|
55
|
+
// AN INSTALLED BUILD IS ALWAYS THE SINGLE-PERSON APP (2026-10-06). The first
|
|
56
|
+
// download from agentbox.ac opened on the team's sign-in page, because the Mac
|
|
57
|
+
// it ran on kept a team key. The founder: "users are no longer using the
|
|
58
|
+
// multiplayer version so this should never be visible." The team version still
|
|
59
|
+
// runs from a checkout (npm run app). See
|
|
60
|
+
// tests/the-installed-app-is-the-single-person-app.test.mjs.
|
|
61
|
+
// AND SO IS ⌘K's NEW-USER WINDOW (w-9f6975906c, 2026-10-05). It runs from her
|
|
62
|
+
// checkout, which holds a team key, so it opened on the same sign-in page:
|
|
63
|
+
// "when i run this command i got the login screen again ... this was the
|
|
64
|
+
// critical thing to fix". A new user is a new user of the single-person app.
|
|
65
|
+
export function loadCloudConfig(appDir, { packaged = isPackagedElectron(), home = os.homedir(), env = process.env } = {}) {
|
|
66
|
+
if (packaged || runningAsAFreshUser(env)) return null;
|
|
67
|
+
const tries = [
|
|
68
|
+
!packaged && process.env.AGENTBOX_TEAM_CONFIG,
|
|
69
|
+
path.join(appDir, 'cloud', 'team.config.json'),
|
|
70
|
+
teamConfigOnThisMac(home),
|
|
71
|
+
];
|
|
72
|
+
for (const file of tries) {
|
|
73
|
+
if (!file) continue;
|
|
74
|
+
try {
|
|
75
|
+
const c = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
76
|
+
if (typeof c.url === 'string' && typeof c.anonKey === 'string') return c;
|
|
77
|
+
} catch { /* try the next place; none of them is the single-person app */ }
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fileStorage(file, { encrypt, decrypt }) {
|
|
83
|
+
const read = () => {
|
|
84
|
+
try {
|
|
85
|
+
const raw = fs.readFileSync(file);
|
|
86
|
+
const text = decrypt ? decrypt(raw) : raw.toString('utf8');
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
} catch { return {}; }
|
|
89
|
+
};
|
|
90
|
+
const write = (data) => {
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
92
|
+
const text = JSON.stringify(data);
|
|
93
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
94
|
+
fs.writeFileSync(tmp, encrypt ? encrypt(text) : text, { mode: 0o600 });
|
|
95
|
+
fs.renameSync(tmp, file);
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
getItem: (key) => read()[key] ?? null,
|
|
99
|
+
setItem: (key, value) => { const d = read(); d[key] = value; write(d); },
|
|
100
|
+
removeItem: (key) => { const d = read(); delete d[key]; write(d); },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function supabaseSession({ cloudConfig, sessionFile, encrypt = null, decrypt = null, openExternal, packaged = isPackagedElectron(), testLogin = process.env.AGENTBOX_TEAM_TEST_LOGIN }) {
|
|
105
|
+
const configured = !!cloudConfig;
|
|
106
|
+
if (packaged) testLogin = null;
|
|
107
|
+
let client = null;
|
|
108
|
+
|
|
109
|
+
async function getClient() {
|
|
110
|
+
if (client) return client;
|
|
111
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
112
|
+
client = createClient(cloudConfig.url, cloudConfig.anonKey, {
|
|
113
|
+
auth: {
|
|
114
|
+
flowType: 'pkce',
|
|
115
|
+
storage: fileStorage(sessionFile, { encrypt, decrypt }),
|
|
116
|
+
persistSession: true,
|
|
117
|
+
autoRefreshToken: true,
|
|
118
|
+
detectSessionInUrl: false,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return client;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
configured,
|
|
126
|
+
|
|
127
|
+
async restore() {
|
|
128
|
+
if (!configured) return null;
|
|
129
|
+
const c = await getClient();
|
|
130
|
+
const { data } = await c.auth.getSession();
|
|
131
|
+
if (data?.session) return supabaseBackend(c);
|
|
132
|
+
// A test harness signs a throwaway person in with a password, since a
|
|
133
|
+
// test cannot click through Google. Never set in the app itself.
|
|
134
|
+
if (testLogin) {
|
|
135
|
+
const at = testLogin.indexOf(':');
|
|
136
|
+
const { error } = await c.auth.signInWithPassword({ email: testLogin.slice(0, at), password: testLogin.slice(at + 1) });
|
|
137
|
+
if (error) throw new Error(`test sign-in: ${error.message}`);
|
|
138
|
+
return supabaseBackend(c);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async signIn({ onUrl, log } = {}) {
|
|
144
|
+
if (!configured) throw new Error('this build has no team cloud configured');
|
|
145
|
+
const c = await getClient();
|
|
146
|
+
await signInWithGoogle({ client: c, openExternal, onUrl, log, returnUrl: cloudConfig.signInReturn || 'http://127.0.0.1:54783/auth/callback' });
|
|
147
|
+
return supabaseBackend(c);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// EMAIL AND PASSWORD, beside Google (2026-10-01: the team version needs
|
|
151
|
+
// a real sign-in page). Google stays the first way in; this is the one that works
|
|
152
|
+
// before a team has set Google up, and for anyone who would rather not.
|
|
153
|
+
async signInWithPassword(email, password) {
|
|
154
|
+
if (!configured) throw new Error('this build has no team cloud configured');
|
|
155
|
+
const c = await getClient();
|
|
156
|
+
const { error } = await c.auth.signInWithPassword({ email: String(email ?? '').trim(), password: String(password ?? '') });
|
|
157
|
+
if (error) throw new Error(plainAuthError(error.message));
|
|
158
|
+
return supabaseBackend(c);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// A new account. With email confirmation on (the hosted project's setting)
|
|
162
|
+
// there is no session until the link in the email is followed, so this
|
|
163
|
+
// answers { confirm: true } and the page says to check the inbox.
|
|
164
|
+
async signUp({ email, password, name }) {
|
|
165
|
+
if (!configured) throw new Error('this build has no team cloud configured');
|
|
166
|
+
const c = await getClient();
|
|
167
|
+
const { data, error } = await c.auth.signUp({
|
|
168
|
+
email: String(email ?? '').trim(),
|
|
169
|
+
password: String(password ?? ''),
|
|
170
|
+
options: { data: { full_name: String(name ?? '').trim() || undefined } },
|
|
171
|
+
});
|
|
172
|
+
if (error) throw new Error(plainAuthError(error.message));
|
|
173
|
+
return data?.session ? { backend: supabaseBackend(c) } : { confirm: true };
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
cancelSignIn: () => cancelGoogleSignIn(),
|
|
177
|
+
// Only ever the link this sign-in made; the window never names one.
|
|
178
|
+
reopen: (url) => openExternal(url),
|
|
179
|
+
|
|
180
|
+
async signOut() {
|
|
181
|
+
if (client) await client.auth.signOut().catch(() => {});
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The cloud's words, said the way the page says everything else.
|
|
187
|
+
export function plainAuthError(message) {
|
|
188
|
+
const m = String(message ?? '');
|
|
189
|
+
if (/invalid login credentials/i.test(m)) return 'That email and password do not match an account.';
|
|
190
|
+
if (/email not confirmed/i.test(m)) return 'Confirm your email first: follow the link we sent, then sign in.';
|
|
191
|
+
if (/already registered|already been registered/i.test(m)) return 'There is already an account with that email. Sign in instead.';
|
|
192
|
+
if (/password should be at least/i.test(m)) return 'Use a password of at least 6 characters.';
|
|
193
|
+
if (/rate limit/i.test(m)) return 'Too many tries just now. Wait a minute and try again.';
|
|
194
|
+
return m || 'That did not work. Try again.';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// WHERE THE HEADLESS APP KEEPS ITS SIGN-IN: in its own data folder, readable by
|
|
198
|
+
// this user only, never in the store root. The store is the folder people
|
|
199
|
+
// copy, sync and hand to agents, and this file holds a refresh token in plain
|
|
200
|
+
// JSON (review, 2026-10-01). One left in the store by an older copy is moved.
|
|
201
|
+
export function headlessSessionFile({ userDir, storeRoot }) {
|
|
202
|
+
const file = path.join(userDir, 'team-session.json');
|
|
203
|
+
const old = path.join(storeRoot, '.team-session');
|
|
204
|
+
try {
|
|
205
|
+
if (!fs.existsSync(file) && fs.existsSync(old)) {
|
|
206
|
+
fs.mkdirSync(userDir, { recursive: true });
|
|
207
|
+
fs.renameSync(old, file);
|
|
208
|
+
}
|
|
209
|
+
if (fs.existsSync(file)) fs.chmodSync(file, 0o600);
|
|
210
|
+
} catch { /* a session that cannot be moved is a sign-in to do again */ }
|
|
211
|
+
return file;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The same session shape over the in-memory cloud, for tests.
|
|
215
|
+
export function memorySession(backendFor) {
|
|
216
|
+
let signedIn = null;
|
|
217
|
+
return {
|
|
218
|
+
configured: true,
|
|
219
|
+
async restore() { return signedIn; },
|
|
220
|
+
async signIn() { signedIn = backendFor(); return signedIn; },
|
|
221
|
+
async signInWithPassword() { signedIn = backendFor(); return signedIn; },
|
|
222
|
+
async signUp() { return { confirm: true }; },
|
|
223
|
+
async signOut() { signedIn = null; },
|
|
224
|
+
};
|
|
225
|
+
}
|