@empir3/empir3-bridge 0.3.21

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. package/tray/tray.py +1843 -0
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Koba Installer Server — local HTTP server behind the chat UI.
4
+ *
5
+ * Flow:
6
+ * 1. Pick a random localhost port, start the HTTP server.
7
+ * 2. Open the user's default browser to http://localhost:<port>/
8
+ * 3. Serve the chat UI; respond to its fetch() calls:
9
+ * POST /api/login { email, password }
10
+ * POST /api/signup { email, password, name }
11
+ * POST /api/launch { token, user } → writes auth+settings, spawns bridge
12
+ * POST /api/open-browser → launches Chrome on /connect?oauth_token=...
13
+ * POST /api/close → exit after a delay so the fetch resolves
14
+ * 4. When the chat is done, installer exits and the detached Bridge takes over.
15
+ *
16
+ * This file never embeds credentials. All auth calls go to app.empir3.com
17
+ * which issues the token and sets the server-side cookie; we just relay the
18
+ * POST and persist the returned token locally.
19
+ */
20
+ const http = require('http');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const { spawn, exec } = require('child_process');
25
+ const https = require('https');
26
+
27
+ const SERVER = process.env.EMPIR3_SERVER || 'https://app.empir3.com';
28
+ // In dev runs UI files are next to this script; in SEA-packaged runs the
29
+ // payload tarball ships installer-ui/ alongside this bundle and the path
30
+ // is surfaced through an env var by build/payload-entry.js.
31
+ const UI_DIR = process.env.EMPIR3_BRIDGE_INSTALLER_UI_DIR || path.join(__dirname, 'ui');
32
+ const APPDATA = path.join(process.env.APPDATA || path.join(os.homedir(), '.empir3'), 'Empir3');
33
+ const AUTH_FILE = path.join(APPDATA, 'bridge-auth.json');
34
+ const SETTINGS_FILE = path.join(APPDATA, 'bridge-settings.json');
35
+
36
+ function ensureAppdata() { try { fs.mkdirSync(APPDATA, { recursive: true }); } catch {} }
37
+
38
+ // ── Tiny HTTPS JSON client (no external deps) ──────────────────────────
39
+
40
+ function postJson(url, body) {
41
+ return new Promise((resolve, reject) => {
42
+ const u = new URL(url);
43
+ const data = JSON.stringify(body);
44
+ const req = https.request({
45
+ hostname: u.hostname,
46
+ port: u.port || 443,
47
+ path: u.pathname + (u.search || ''),
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ 'Content-Length': Buffer.byteLength(data),
52
+ 'User-Agent': 'empir3-bridge-installer',
53
+ },
54
+ }, (res) => {
55
+ let chunks = '';
56
+ res.on('data', (c) => (chunks += c));
57
+ res.on('end', () => {
58
+ let parsed = null;
59
+ try { parsed = JSON.parse(chunks); } catch {}
60
+ resolve({ status: res.statusCode, body: parsed, raw: chunks });
61
+ });
62
+ });
63
+ req.on('error', reject);
64
+ req.write(data);
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ // ── Local state for the installer session ─────────────────────────────
70
+
71
+ let savedAuth = null;
72
+ let bridgeChild = null;
73
+
74
+ // ── HTTP handlers ──────────────────────────────────────────────────────
75
+
76
+ function readBody(req) {
77
+ return new Promise((resolve) => {
78
+ let buf = '';
79
+ req.on('data', (c) => (buf += c));
80
+ req.on('end', () => {
81
+ try { resolve(JSON.parse(buf)); } catch { resolve({}); }
82
+ });
83
+ });
84
+ }
85
+
86
+ function sendJson(res, status, body) {
87
+ const s = JSON.stringify(body);
88
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(s) });
89
+ res.end(s);
90
+ }
91
+
92
+ function serveStatic(req, res) {
93
+ // Map request paths to files under UI_DIR. Default → index.html.
94
+ let rel = req.url.split('?')[0];
95
+ if (rel === '/' || rel === '') rel = '/index.html';
96
+ const safe = path.normalize(rel).replace(/^([/\\])+/, '');
97
+ const filePath = path.join(UI_DIR, safe);
98
+ if (!filePath.startsWith(UI_DIR)) {
99
+ res.writeHead(403); res.end('Forbidden'); return;
100
+ }
101
+ fs.readFile(filePath, (err, data) => {
102
+ if (err) { res.writeHead(404); res.end('Not found'); return; }
103
+ const ext = path.extname(filePath).toLowerCase();
104
+ const ct = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' }[ext] || 'application/octet-stream';
105
+ res.writeHead(200, { 'Content-Type': ct });
106
+ res.end(data);
107
+ });
108
+ }
109
+
110
+ async function handleApi(req, res, pathname) {
111
+ const body = await readBody(req);
112
+
113
+ if (pathname === '/api/login') {
114
+ // NEVER log password material. Even length + last char is enough to
115
+ // accelerate a brute-force / shoulder-surf attack and leaks signal
116
+ // about the user's password. The previous log line shipped to the
117
+ // installer's local console + bridge.log on disk — caught live in
118
+ // cont-13O smoke when the user spotted "[login] ... password.length=14
119
+ // password.lastChar='m'" scrolling past during install.
120
+ console.log(`[login] email=${JSON.stringify(body.email)}`);
121
+ const r = await postJson(`${SERVER}/api/auth/login`, { email: body.email, password: body.password });
122
+ if (r.status === 200 && r.body?.token) {
123
+ savedAuth = { legacyToken: r.body.token, user: r.body.user, channelId: r.body.channelId || null };
124
+ return sendJson(res, 200, { ok: true, user: r.body.user });
125
+ }
126
+ return sendJson(res, r.status, { ok: false, error: r.body?.error || 'Login failed' });
127
+ }
128
+
129
+ if (pathname === '/api/signup') {
130
+ const r = await postJson(`${SERVER}/api/auth/register`, { email: body.email, password: body.password, name: body.name });
131
+ if ((r.status === 200 || r.status === 201) && r.body?.token) {
132
+ savedAuth = { legacyToken: r.body.token, user: r.body.user, channelId: r.body.channelId || null };
133
+ return sendJson(res, 200, { ok: true, user: r.body.user });
134
+ }
135
+ return sendJson(res, r.status, { ok: false, error: r.body?.error || 'Signup failed' });
136
+ }
137
+
138
+ if (pathname === '/api/launch') {
139
+ if (!savedAuth) return sendJson(res, 400, { ok: false, error: 'Not authenticated' });
140
+ ensureAppdata();
141
+ // Persist auth
142
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(savedAuth, null, 2));
143
+ // Ensure a settings file exists; Bridge fills in deviceId on first boot
144
+ if (!fs.existsSync(SETTINGS_FILE)) {
145
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
146
+ deviceName: os.hostname(),
147
+ homeDirectory: path.join(os.homedir(), 'Documents', 'Empir3'),
148
+ permissions: { read: true, write: false, execute: false },
149
+ }, null, 2));
150
+ }
151
+ // Spawn the Bridge — detached. In SEA-packaged runs we invoke the
152
+ // bootstrapper with `--daemon`, which spawns the tray wrapper, which
153
+ // in turn supervises the actual daemon. The user sees a tray icon.
154
+ // In dev runs we still point Node directly at index.js (no tray) so
155
+ // smoke-tests of the installer don't depend on a built Empir3Tray.exe.
156
+ let isSea = false;
157
+ try { isSea = !!require('node:sea').isSea(); } catch {}
158
+ const spawnArgs = isSea ? ['--daemon'] : [path.resolve(__dirname, '..', 'index.js')];
159
+ bridgeChild = spawn(process.execPath, spawnArgs, {
160
+ detached: true,
161
+ stdio: 'ignore',
162
+ env: { ...process.env, RELAY_SECRET: process.env.RELAY_SECRET || '' },
163
+ });
164
+ bridgeChild.unref();
165
+ return sendJson(res, 200, { ok: true, pid: bridgeChild.pid });
166
+ }
167
+
168
+ if (pathname === '/api/bridge-status') {
169
+ // Poll for 'relay.connected' in the recent bridge log
170
+ const logPath = path.join(APPDATA, 'bridge.log');
171
+ let connected = false;
172
+ try {
173
+ const size = fs.statSync(logPath).size;
174
+ const start = Math.max(0, size - 20_000);
175
+ const fd = fs.openSync(logPath, 'r');
176
+ const buf = Buffer.alloc(size - start);
177
+ fs.readSync(fd, buf, 0, buf.length, start);
178
+ fs.closeSync(fd);
179
+ connected = buf.toString('utf8').includes('relay.connected');
180
+ } catch {}
181
+ return sendJson(res, 200, { connected });
182
+ }
183
+
184
+ if (pathname === '/api/open-browser') {
185
+ if (!savedAuth) return sendJson(res, 400, { ok: false, error: 'Not authenticated' });
186
+ const userJson = encodeURIComponent(JSON.stringify(savedAuth.user));
187
+ const target = `${SERVER}/connect?oauth_token=${encodeURIComponent(savedAuth.legacyToken)}&oauth_user=${userJson}`;
188
+ // Open in system default browser (user's regular Chrome, with their cookies + history)
189
+ const cmd = process.platform === 'win32' ? `start "" "${target}"` :
190
+ process.platform === 'darwin' ? `open "${target}"` :
191
+ `xdg-open "${target}"`;
192
+ exec(cmd);
193
+ return sendJson(res, 200, { ok: true, url: target });
194
+ }
195
+
196
+ if (pathname === '/api/close') {
197
+ sendJson(res, 200, { ok: true });
198
+ setTimeout(() => process.exit(0), 400);
199
+ return;
200
+ }
201
+
202
+ sendJson(res, 404, { ok: false, error: 'Not found' });
203
+ }
204
+
205
+ // ── Server ─────────────────────────────────────────────────────────────
206
+
207
+ function start() {
208
+ const server = http.createServer(async (req, res) => {
209
+ const pathname = new URL(req.url, `http://${req.headers.host}`).pathname;
210
+ if (pathname.startsWith('/api/')) {
211
+ try { await handleApi(req, res, pathname); }
212
+ catch (e) { sendJson(res, 500, { ok: false, error: e.message }); }
213
+ return;
214
+ }
215
+ serveStatic(req, res);
216
+ });
217
+
218
+ server.listen(0, '127.0.0.1', () => {
219
+ const { port } = server.address();
220
+ const url = `http://127.0.0.1:${port}/`;
221
+ console.log(`[koba-installer] ${url}`);
222
+ const cmd = process.platform === 'win32' ? `start "" "${url}"` :
223
+ process.platform === 'darwin' ? `open "${url}"` :
224
+ `xdg-open "${url}"`;
225
+ exec(cmd);
226
+ });
227
+ }
228
+
229
+ if (require.main === module) start();
230
+
231
+ module.exports = { start };
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Koba chat — installer front-end.
3
+ *
4
+ * Single state-machine driving the conversation:
5
+ * greet → ask_email → ask_account → (login | signup)
6
+ * ↓
7
+ * wait_connect → offer_browser → done
8
+ *
9
+ * Every agent line renders as a bubble after a pacing delay so Koba doesn't
10
+ * wall-of-text new users. User replies are collected via an input row or a
11
+ * choice row, which we re-render per step.
12
+ */
13
+
14
+ const chat = document.getElementById('chat');
15
+ const action = document.getElementById('action');
16
+ const stage = document.getElementById('stage');
17
+
18
+ const session = { email: '', user: null };
19
+
20
+ // ── Helpers ─────────────────────────────────────────────────────────────
21
+
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
+ const pace = (text) => Math.min(1400, Math.max(450, text.length * 28));
24
+
25
+ function scrollToBottom() {
26
+ chat.scrollTop = chat.scrollHeight;
27
+ }
28
+
29
+ function addRow(kind, html) {
30
+ const row = document.createElement('div');
31
+ row.className = 'row ' + kind;
32
+ row.innerHTML = `<div class="bubble">${html}</div>`;
33
+ chat.appendChild(row);
34
+ scrollToBottom();
35
+ return row;
36
+ }
37
+
38
+ function addSpeaker(name) {
39
+ // Label only once per streak of agent bubbles
40
+ const last = chat.lastElementChild;
41
+ if (last?.classList.contains('speaker') && last.textContent === name) return;
42
+ if (last?.classList.contains('row') && last.classList.contains('agent')) return;
43
+ const s = document.createElement('div');
44
+ s.className = 'speaker';
45
+ s.textContent = name;
46
+ chat.appendChild(s);
47
+ }
48
+
49
+ function showTyping() {
50
+ const row = document.createElement('div');
51
+ row.className = 'row agent typing-row';
52
+ row.innerHTML = `<div class="bubble"><span class="typing"><span></span><span></span><span></span></span></div>`;
53
+ chat.appendChild(row);
54
+ scrollToBottom();
55
+ return row;
56
+ }
57
+
58
+ async function koba(text) {
59
+ addSpeaker('Koba');
60
+ const typing = showTyping();
61
+ await sleep(pace(text));
62
+ typing.remove();
63
+ addRow('agent', escapeHtml(text));
64
+ }
65
+
66
+ function me(text) {
67
+ addRow('user', escapeHtml(text));
68
+ }
69
+
70
+ function escapeHtml(s) {
71
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
72
+ }
73
+
74
+ function setAction(html) {
75
+ action.innerHTML = html;
76
+ }
77
+
78
+ function clearAction() {
79
+ action.innerHTML = '';
80
+ }
81
+
82
+ function setStage(label) {
83
+ stage.textContent = label;
84
+ }
85
+
86
+ function askInput({ placeholder = '', type = 'text', submitLabel = 'Send' }) {
87
+ return new Promise((resolve) => {
88
+ setAction(`
89
+ <div class="input-row">
90
+ <input id="inp" type="${type}" placeholder="${escapeHtml(placeholder)}" autofocus />
91
+ <button class="primary" id="btn">${escapeHtml(submitLabel)}</button>
92
+ </div>
93
+ `);
94
+ const inp = document.getElementById('inp');
95
+ const btn = document.getElementById('btn');
96
+ const done = () => {
97
+ const v = inp.value.trim();
98
+ if (!v) return;
99
+ clearAction();
100
+ resolve(v);
101
+ };
102
+ btn.onclick = done;
103
+ inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') done(); });
104
+ inp.focus();
105
+ });
106
+ }
107
+
108
+ function askChoice(options) {
109
+ return new Promise((resolve) => {
110
+ const buttons = options
111
+ .map((o, i) => `<button class="${o.primary ? 'primary' : 'secondary'}" data-i="${i}">${escapeHtml(o.label)}</button>`)
112
+ .join('');
113
+ setAction(`<div class="choices">${buttons}</div>`);
114
+ action.querySelectorAll('button').forEach((b) => {
115
+ b.onclick = () => {
116
+ const opt = options[parseInt(b.dataset.i, 10)];
117
+ clearAction();
118
+ resolve(opt.value);
119
+ };
120
+ });
121
+ });
122
+ }
123
+
124
+ async function api(path, body) {
125
+ const res = await fetch(path, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify(body || {}),
129
+ });
130
+ return res.json();
131
+ }
132
+
133
+ // ── Flow ────────────────────────────────────────────────────────────────
134
+
135
+ async function run() {
136
+ setStage('Greeting');
137
+ await koba("Hey — I'm Koba. I'm going to get your team set up so Vincent and the rest of us can help you out directly from your machine.");
138
+ await koba("It's quick. I just need your email, and if you don't have an Empir3 account yet, we'll make one together.");
139
+
140
+ // Loop so the user can correct themselves
141
+ let email = '';
142
+ while (!email) {
143
+ setStage('Email');
144
+ const v = await askInput({ placeholder: 'you@domain.com', type: 'email', submitLabel: 'Next' });
145
+ me(v);
146
+ if (!v.includes('@')) {
147
+ await koba("That doesn't look like an email — give me one with an @ in it?");
148
+ continue;
149
+ }
150
+ email = v;
151
+ }
152
+ session.email = email;
153
+
154
+ setStage('Account');
155
+ const mode = await askChoice([
156
+ { label: 'I already have one', value: 'login', primary: true },
157
+ { label: 'Create one for me', value: 'signup' },
158
+ ]);
159
+ me(mode === 'login' ? 'I already have an Empir3 account' : 'Create one for me');
160
+
161
+ if (mode === 'login') {
162
+ await runLogin(email);
163
+ } else {
164
+ await runSignup(email);
165
+ }
166
+
167
+ // After auth, session.user is set and the Bridge gets launched.
168
+ await runConnect();
169
+ await runOpenBrowser();
170
+ await runGoodbye();
171
+ }
172
+
173
+ async function runLogin(email) {
174
+ while (true) {
175
+ setStage('Password');
176
+ await koba("What's your password?");
177
+ const pwd = await askInput({ placeholder: 'password', type: 'password', submitLabel: 'Sign in' });
178
+ me('••••••••');
179
+ await koba("One second, signing you in…");
180
+ const r = await api('/api/login', { email, password: pwd });
181
+ if (r.ok) {
182
+ session.user = r.user;
183
+ await koba(`Welcome back, ${r.user?.name || email}.`);
184
+ return;
185
+ }
186
+ await koba(`Hmm — ${r.error || 'that didn\'t work'}. Want to try again?`);
187
+ const retry = await askChoice([
188
+ { label: 'Try again', value: 'retry', primary: true },
189
+ { label: "I don't have an account — make one", value: 'signup' },
190
+ ]);
191
+ me(retry === 'retry' ? 'Try again' : 'Make one for me');
192
+ if (retry === 'signup') return runSignup(email);
193
+ }
194
+ }
195
+
196
+ async function runSignup(email) {
197
+ let name = '';
198
+ while (!name) {
199
+ setStage('Your name');
200
+ await koba("Nice. What should I call you?");
201
+ const v = await askInput({ placeholder: 'Your name', submitLabel: 'Next' });
202
+ me(v);
203
+ if (v.length > 100) { await koba("Let's keep it under 100 characters."); continue; }
204
+ name = v;
205
+ }
206
+
207
+ while (true) {
208
+ setStage('Password');
209
+ await koba("Pick a password — eight characters minimum.");
210
+ const pwd = await askInput({ placeholder: 'password (8+ chars)', type: 'password', submitLabel: 'Create account' });
211
+ me('••••••••');
212
+ if (pwd.length < 8) {
213
+ await koba("Eight characters or more — that's the rule. Try again?");
214
+ continue;
215
+ }
216
+ await koba("Got it. Creating your account…");
217
+ const r = await api('/api/signup', { email, password: pwd, name });
218
+ if (r.ok) {
219
+ session.user = r.user;
220
+ await koba(`You're in, ${r.user?.name || name}. Account created.`);
221
+ return;
222
+ }
223
+ await koba(`That didn't work — ${r.error || 'something went wrong'}. Want to try a different password?`);
224
+ const retry = await askChoice([
225
+ { label: 'Try again', value: 'retry', primary: true },
226
+ { label: 'I actually have an account', value: 'login' },
227
+ ]);
228
+ me(retry === 'retry' ? 'Try again' : 'I actually have one');
229
+ if (retry === 'login') return runLogin(email);
230
+ }
231
+ }
232
+
233
+ async function runConnect() {
234
+ setStage('Connecting Bridge');
235
+ await koba("Now I'm going to fire up the Bridge on your machine — that's the piece that lets the team reach you.");
236
+ await api('/api/launch', {});
237
+
238
+ // Poll bridge-status for up to 20s
239
+ const deadline = Date.now() + 20_000;
240
+ while (Date.now() < deadline) {
241
+ const r = await api('/api/bridge-status', {});
242
+ if (r.connected) {
243
+ await koba("Bridge is up and talking to the team.");
244
+ return;
245
+ }
246
+ await sleep(800);
247
+ }
248
+ await koba("The Bridge is taking a bit longer than usual — it should connect on its own shortly. You can keep going.");
249
+ }
250
+
251
+ async function runOpenBrowser() {
252
+ setStage('Open browser?');
253
+ const choice = await askChoice([
254
+ { label: 'Yes, open it and log me in', value: 'yes', primary: true },
255
+ { label: 'Not now', value: 'no' },
256
+ ]);
257
+ me(choice === 'yes' ? 'Yes, open it' : 'Not right now');
258
+
259
+ if (choice === 'yes') {
260
+ await koba("On it. Opening Empir3 in your browser now.");
261
+ await api('/api/open-browser', {});
262
+ } else {
263
+ await koba("No problem. You can open app.empir3.com whenever — you're already signed in.");
264
+ }
265
+ }
266
+
267
+ async function runGoodbye() {
268
+ setStage('Done');
269
+ await koba("You're all set. The Bridge will stay running in the background. I'll close this window now.");
270
+ await sleep(1200);
271
+ await api('/api/close', {});
272
+ }
273
+
274
+ // ── Go ──────────────────────────────────────────────────────────────────
275
+
276
+ run().catch(async (e) => {
277
+ await koba(`Something went wrong on my end: ${e.message}. You can close this window and try again.`);
278
+ });
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Empir3 — Setup with Koba</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+ <header class="top">
11
+ <div class="brand">
12
+ <span class="dot"></span>
13
+ <span class="name">Empir3</span>
14
+ </div>
15
+ <div class="stage" id="stage">Setup</div>
16
+ </header>
17
+
18
+ <main class="chat" id="chat"></main>
19
+
20
+ <footer class="action" id="action"></footer>
21
+
22
+ <script src="app.js"></script>
23
+ </body>
24
+ </html>
@@ -0,0 +1,146 @@
1
+ :root {
2
+ --bg: #faf8f4;
3
+ --ink: #1a1716;
4
+ --muted: #8a8480;
5
+ --line: #e8e2d9;
6
+ --accent: #14B8A6;
7
+ --accent-ink: #ffffff;
8
+ --surface: #ffffff;
9
+ --agent-bubble: #f1ede5;
10
+ --user-bubble: #14B8A6;
11
+ --user-ink: #ffffff;
12
+ --radius: 16px;
13
+ --radius-sm: 10px;
14
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
15
+ --font-serif: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
16
+ }
17
+
18
+ * { box-sizing: border-box; }
19
+ html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--font); }
20
+
21
+ body { display: flex; flex-direction: column; }
22
+
23
+ header.top {
24
+ display: flex; align-items: center; justify-content: space-between;
25
+ padding: 14px 22px;
26
+ border-bottom: 1px solid var(--line);
27
+ background: rgba(250, 248, 244, 0.9);
28
+ backdrop-filter: blur(8px);
29
+ position: sticky; top: 0; z-index: 5;
30
+ }
31
+
32
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; letter-spacing: -0.01em; }
33
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
34
+ .name { font-family: var(--font-serif); font-size: 18px; }
35
+ .stage { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
36
+
37
+ main.chat {
38
+ flex: 1 1 auto;
39
+ overflow-y: auto;
40
+ padding: 24px min(4vw, 40px);
41
+ display: flex;
42
+ flex-direction: column;
43
+ max-width: 720px;
44
+ width: 100%;
45
+ margin: 0 auto;
46
+ }
47
+
48
+ .row { display: flex; margin-bottom: 6px; }
49
+ .row.agent { justify-content: flex-start; }
50
+ .row.user { justify-content: flex-end; }
51
+ .row + .row { margin-top: 2px; }
52
+ .row.agent + .row.agent .bubble { border-top-left-radius: 4px; }
53
+ .row.user + .row.user .bubble { border-top-right-radius: 4px; }
54
+
55
+ .bubble {
56
+ max-width: 78%;
57
+ padding: 11px 15px;
58
+ border-radius: var(--radius);
59
+ line-height: 1.45;
60
+ font-size: 15px;
61
+ word-wrap: break-word;
62
+ box-shadow: 0 1px 1px rgba(26,23,22,0.04);
63
+ }
64
+ .row.agent .bubble {
65
+ background: var(--agent-bubble);
66
+ color: var(--ink);
67
+ border-top-left-radius: var(--radius);
68
+ border-top-right-radius: var(--radius);
69
+ border-bottom-left-radius: 6px;
70
+ border-bottom-right-radius: var(--radius);
71
+ }
72
+ .row.user .bubble {
73
+ background: var(--user-bubble);
74
+ color: var(--user-ink);
75
+ border-top-left-radius: var(--radius);
76
+ border-top-right-radius: var(--radius);
77
+ border-bottom-left-radius: var(--radius);
78
+ border-bottom-right-radius: 6px;
79
+ }
80
+
81
+ .speaker {
82
+ font-size: 12px;
83
+ color: var(--muted);
84
+ margin: 10px 0 4px 4px;
85
+ letter-spacing: 0.02em;
86
+ }
87
+
88
+ .typing { display: inline-flex; gap: 3px; padding: 6px 2px; }
89
+ .typing span {
90
+ width: 6px; height: 6px; border-radius: 50%;
91
+ background: var(--muted); opacity: 0.6;
92
+ animation: blink 1.2s infinite;
93
+ }
94
+ .typing span:nth-child(2) { animation-delay: 0.15s; }
95
+ .typing span:nth-child(3) { animation-delay: 0.3s; }
96
+ @keyframes blink { 0%, 60%, 100% { opacity: 0.3; } 30% { opacity: 0.95; } }
97
+
98
+ footer.action {
99
+ border-top: 1px solid var(--line);
100
+ padding: 14px min(4vw, 40px);
101
+ background: var(--surface);
102
+ max-width: 720px; margin: 0 auto; width: 100%;
103
+ }
104
+
105
+ .input-row { display: flex; gap: 10px; align-items: stretch; }
106
+ .input-row input {
107
+ flex: 1;
108
+ padding: 12px 14px;
109
+ border-radius: var(--radius-sm);
110
+ border: 1px solid var(--line);
111
+ background: var(--bg);
112
+ color: var(--ink);
113
+ font-family: inherit;
114
+ font-size: 15px;
115
+ outline: none;
116
+ transition: border-color 0.15s;
117
+ }
118
+ .input-row input:focus { border-color: var(--accent); }
119
+
120
+ button.primary, button.secondary {
121
+ padding: 12px 18px;
122
+ border-radius: var(--radius-sm);
123
+ border: 0;
124
+ font-family: inherit;
125
+ font-weight: 600;
126
+ font-size: 15px;
127
+ cursor: pointer;
128
+ transition: transform 0.08s, box-shadow 0.15s;
129
+ }
130
+ button.primary { background: var(--accent); color: var(--accent-ink); }
131
+ button.primary:hover { box-shadow: 0 2px 10px rgba(20, 184, 166, 0.3); }
132
+ button.primary:active { transform: translateY(1px); }
133
+ button.secondary { background: transparent; color: var(--ink); border: 1px solid var(--line); }
134
+ button.secondary:hover { background: var(--agent-bubble); }
135
+
136
+ .choices { display: flex; gap: 10px; flex-wrap: wrap; }
137
+ .choices button { flex: 1; min-width: 140px; }
138
+
139
+ .error { color: #c53030; font-size: 13px; margin-top: 8px; }
140
+ .hint { color: var(--muted); font-size: 13px; margin-top: 6px; }
141
+
142
+ .link-btn {
143
+ background: none; border: 0; color: var(--accent);
144
+ font-family: inherit; font-size: inherit; cursor: pointer;
145
+ padding: 0; text-decoration: underline; text-underline-offset: 3px;
146
+ }