@bill10/agent-007 0.6.2

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
package/public/app.js ADDED
@@ -0,0 +1,650 @@
1
+ // Main init + message routing
2
+ import { agents, repos, selfUserId, setSelf, shellPreset, setView, setBillionEnabled } from './modules/state.js';
3
+ import { connect, send } from './modules/ws.js';
4
+ import {
5
+ handleSessionCreated, handlePtyOutput, handlePtySize, handleStateChange,
6
+ handleSpawnError, handleSessionEnded, switchToSession,
7
+ removeSession, updateTabs, updateStatusBar, fitActiveTerminal,
8
+ setOnSessionChanged, handleUploadComplete, setupUpload,
9
+ updateTerminalThemes, updateTopbarAgent,
10
+ } from './modules/terminal.js';
11
+ import {
12
+ renderOffice, setupOfficeClick, startAnimationLoop,
13
+ noteAgentArrival, noteAgentDeparture, noteJobsUpdate,
14
+ } from './modules/office.js';
15
+ import {
16
+ setupExplorer, handleReposList, handleFileTree, handleFullTree,
17
+ handleConflictsUpdate, handleFileDiff, handleOrphansList,
18
+ renderExplorer, closeDiffViewer,
19
+ handleRepoError as explorerHandleRepoError,
20
+ } from './modules/explorer.js';
21
+ import { setupShortcuts } from './modules/shortcuts.js';
22
+ import { setupVoice, stopVoice } from './modules/voice.js';
23
+ import { setupJobBoard, handleJobsList, renderBoard, closeJobForm } from './modules/jobs.js';
24
+ import { isAbsolutePath, joinBrowsePath } from './modules/paths.js';
25
+ import { captureTokenFromUrl, authHeaders, showLogin, renderPresence, escapeHtml } from './modules/auth.js';
26
+
27
+ // Cross-module coordination: when sessions change, re-render office + explorer
28
+ setOnSessionChanged(() => {
29
+ closeDiffViewer();
30
+ renderOffice();
31
+ renderExplorer();
32
+ // Agent state drives the board's "needs you" badges, so they refresh on the
33
+ // same signal as the office and explorer.
34
+ renderBoard();
35
+ });
36
+
37
+ // The board tab's attention badge is derived from the job list, so a jobs-list
38
+ // broadcast has to repaint the tab bar as well as the board itself.
39
+ window._onBoardVisibilityChanged = () => updateTabs();
40
+
41
+ // --- Spawn Form ---
42
+ const DEFAULT_COMMAND = 'claude';
43
+
44
+ // Reassigned by setupSpawnForm; called again when the welcome message arrives
45
+ // so a form opened during the connect window picks up the server's real OS.
46
+ let refreshSpawnPresets = () => {};
47
+
48
+ function setupSpawnForm() {
49
+ const form = document.getElementById('spawn-form');
50
+ const cmdInput = document.getElementById('spawn-command');
51
+ const nameInput = document.getElementById('spawn-name');
52
+ const branchInput = document.getElementById('spawn-branch');
53
+ const startPointInput = document.getElementById('spawn-start-point');
54
+ const repoInput = document.getElementById('spawn-repo');
55
+ const repoDropdown = document.getElementById('repo-dropdown');
56
+ const advancedToggle = document.getElementById('spawn-advanced-toggle');
57
+ const advancedSection = document.getElementById('spawn-advanced');
58
+ const presetsEl = document.getElementById('spawn-presets');
59
+
60
+ // Presets fill the Command field in Advanced; rendered on each open because
61
+ // the server platform arrives async in the welcome message.
62
+ function renderPresets() {
63
+ const presets = [
64
+ { label: 'Claude Code', cmd: DEFAULT_COMMAND },
65
+ { label: 'Codex', cmd: 'codex' },
66
+ { label: 'Gemini', cmd: 'gemini' },
67
+ shellPreset(),
68
+ ];
69
+ presetsEl.innerHTML = '';
70
+ for (const p of presets) {
71
+ const btn = document.createElement('button');
72
+ btn.type = 'button';
73
+ btn.className = 'spawn-preset-btn';
74
+ btn.textContent = p.label;
75
+ btn.dataset.cmd = p.cmd;
76
+ btn.onclick = () => {
77
+ cmdInput.value = p.cmd;
78
+ highlightPreset();
79
+ };
80
+ presetsEl.appendChild(btn);
81
+ }
82
+ highlightPreset();
83
+ }
84
+
85
+ function highlightPreset() {
86
+ const cmd = cmdInput.value.trim();
87
+ for (const btn of presetsEl.children) {
88
+ btn.classList.toggle('selected', btn.dataset.cmd === cmd);
89
+ }
90
+ }
91
+ cmdInput.addEventListener('input', highlightPreset);
92
+ refreshSpawnPresets = renderPresets;
93
+
94
+ advancedToggle.onclick = () => {
95
+ const open = advancedSection.style.display !== 'none';
96
+ advancedSection.style.display = open ? 'none' : 'flex';
97
+ advancedToggle.innerHTML = open ? 'Advanced ▶' : 'Advanced ▼';
98
+ };
99
+
100
+ const errorEl = document.getElementById('spawn-form-error');
101
+ function showSpawnError(text) {
102
+ errorEl.textContent = text;
103
+ errorEl.style.display = 'block';
104
+ }
105
+ function clearSpawnError() {
106
+ errorEl.textContent = '';
107
+ errorEl.style.display = 'none';
108
+ }
109
+
110
+ // Both header buttons sit in the top 36px strip, above this overlay's 40px
111
+ // offset, so they stay clickable while it is open. Each one dismisses the
112
+ // other's form; openJobForm() handles the reverse direction.
113
+ document.getElementById('btn-new-agent').onclick = () => {
114
+ closeJobForm();
115
+ form.style.display = 'flex';
116
+ nameInput.value = '';
117
+ branchInput.value = '';
118
+ startPointInput.value = '';
119
+ cmdInput.value = DEFAULT_COMMAND;
120
+ renderPresets();
121
+ advancedSection.style.display = 'none';
122
+ advancedToggle.innerHTML = 'Advanced ▶';
123
+ clearSpawnError();
124
+ updateRepoDropdown();
125
+ repoInput.focus();
126
+ };
127
+
128
+ document.getElementById('btn-spawn-cancel').onclick = () => {
129
+ form.style.display = 'none';
130
+ clearSpawnError();
131
+ };
132
+
133
+ const startBtn = document.getElementById('btn-spawn-start');
134
+ let spawnTimeout = null;
135
+
136
+ function doSpawn() {
137
+ const command = cmdInput.value.trim() || DEFAULT_COMMAND;
138
+ const name = nameInput.value.trim() || undefined;
139
+ const branch = branchInput.value.trim() || undefined;
140
+ const repoPath = repoInput.value.trim() || undefined;
141
+ // Blank means the repo's base branch on the remote, freshly fetched.
142
+ const startPoint = startPointInput.value.trim() || undefined;
143
+ clearSpawnError();
144
+ const ok = send({ type: 'spawn', command, name, branch, repoPath, startPoint });
145
+ if (!ok) {
146
+ showSpawnError('Not connected to server — refresh the page and try again.');
147
+ return;
148
+ }
149
+ startBtn.disabled = true;
150
+ startBtn.textContent = 'Creating...';
151
+ // Generous, because spawning now fetches the repo before branching so the
152
+ // agent starts from the current remote base. Must stay above the server's
153
+ // FETCH_TIMEOUT (server/git.js) plus the worktree add and the PTY spawn, or
154
+ // a slow network makes this report a timeout for a spawn that then succeeds.
155
+ spawnTimeout = setTimeout(() => {
156
+ resetSpawnForm();
157
+ showSpawnError('Spawn timed out — server did not reply within 30 seconds. Check server logs.');
158
+ }, 30000);
159
+ }
160
+
161
+ function resetSpawnForm() {
162
+ startBtn.disabled = false;
163
+ startBtn.textContent = 'Start';
164
+ clearTimeout(spawnTimeout);
165
+ }
166
+
167
+ window._onSessionCreatedCloseSpawn = () => {
168
+ // Only clear savedActiveTab if we were actually spawning (form was visible)
169
+ if (form.style.display !== 'none') {
170
+ savedActiveTab = null;
171
+ }
172
+ form.style.display = 'none';
173
+ clearSpawnError();
174
+ resetSpawnForm();
175
+ };
176
+ window._onSpawnErrorInForm = (error) => {
177
+ resetSpawnForm();
178
+ showSpawnError(`Error: ${error}`);
179
+ };
180
+
181
+ startBtn.onclick = doSpawn;
182
+ cmdInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSpawn(); });
183
+ nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSpawn(); });
184
+ branchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSpawn(); });
185
+ startPointInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSpawn(); });
186
+ repoInput.addEventListener('keydown', (e) => {
187
+ if (e.key === 'Enter') doSpawn();
188
+ if (e.key === 'Escape') repoDropdown.style.display = 'none';
189
+ });
190
+
191
+ // Combo-box: show dropdown on focus, filter on input
192
+ repoInput.addEventListener('focus', () => {
193
+ updateRepoDropdown();
194
+ if (repos.size > 0) repoDropdown.style.display = 'block';
195
+ });
196
+ repoInput.addEventListener('input', () => {
197
+ updateRepoDropdown();
198
+ });
199
+ repoInput.addEventListener('blur', () => {
200
+ // Delay to allow click on dropdown item
201
+ setTimeout(() => { repoDropdown.style.display = 'none'; }, 150);
202
+ });
203
+
204
+ form.onclick = (e) => { if (e.target === form) form.style.display = 'none'; };
205
+
206
+ // Directory browser
207
+ document.getElementById('btn-browse-repo').onclick = () => openDirBrowser(repoInput.value.trim() || '');
208
+ }
209
+
210
+ // --- Directory Browser ---
211
+ let dirBrowserCurrentPath = '';
212
+ let dirBrowserIsGitRepo = false;
213
+ let dirBrowserOnSelect = null;
214
+
215
+ function openDirBrowser(startPath, onSelect) {
216
+ dirBrowserOnSelect = onSelect || null;
217
+ document.getElementById('dir-browser-modal').style.display = 'flex';
218
+ fetchDirectory(isAbsolutePath(startPath) ? startPath : '');
219
+ }
220
+
221
+ function closeDirBrowser(selectedPath) {
222
+ document.getElementById('dir-browser-modal').style.display = 'none';
223
+ if (selectedPath) {
224
+ if (dirBrowserOnSelect) {
225
+ dirBrowserOnSelect(selectedPath);
226
+ } else {
227
+ document.getElementById('spawn-repo').value = selectedPath;
228
+ }
229
+ }
230
+ dirBrowserOnSelect = null;
231
+ }
232
+
233
+ async function fetchDirectory(path) {
234
+ const list = document.getElementById('dir-browser-list');
235
+ list.innerHTML = '<div class="dir-browser-empty">Loading...</div>';
236
+
237
+ const url = path ? `/api/browse?path=${encodeURIComponent(path)}` : '/api/browse';
238
+ try {
239
+ const resp = await fetch(url, { headers: authHeaders() });
240
+ if (resp.status === 401) {
241
+ showLogin();
242
+ list.innerHTML = '<div class="dir-browser-error">Not authorized</div>';
243
+ return;
244
+ }
245
+ const data = await resp.json();
246
+ if (!resp.ok) {
247
+ list.innerHTML = `<div class="dir-browser-error">${escapeHtml(data.error || 'Failed to browse')}</div>`;
248
+ return;
249
+ }
250
+ dirBrowserCurrentPath = data.path;
251
+ dirBrowserIsGitRepo = data.isGitRepo;
252
+ document.getElementById('dir-browser-path').textContent = data.path;
253
+ const selectBtn = document.getElementById('btn-dir-browser-select');
254
+ selectBtn.disabled = !data.isGitRepo;
255
+ selectBtn.textContent = data.isGitRepo ? 'Select This Directory' : 'Select (not a git repo)';
256
+ renderDirList(data);
257
+ } catch (err) {
258
+ list.innerHTML = `<div class="dir-browser-error">Network error: ${escapeHtml(err.message)}</div>`;
259
+ }
260
+ }
261
+
262
+ function renderDirList(data) {
263
+ const list = document.getElementById('dir-browser-list');
264
+ list.innerHTML = '';
265
+
266
+ // Parent entry
267
+ if (data.parent !== null) {
268
+ const parentEl = document.createElement('div');
269
+ parentEl.className = 'dir-browser-entry';
270
+ parentEl.innerHTML = '<span class="dir-browser-entry-icon">..</span><span class="dir-browser-entry-name">(parent directory)</span>';
271
+ parentEl.onclick = () => fetchDirectory(data.parent);
272
+ list.appendChild(parentEl);
273
+ }
274
+
275
+ if (data.entries.length === 0) {
276
+ const empty = document.createElement('div');
277
+ empty.className = 'dir-browser-empty';
278
+ empty.textContent = 'No subdirectories';
279
+ list.appendChild(empty);
280
+ return;
281
+ }
282
+
283
+ for (const entry of data.entries) {
284
+ const el = document.createElement('div');
285
+ el.className = `dir-browser-entry${entry.isGitRepo ? ' is-git' : ''}`;
286
+
287
+ const icon = document.createElement('span');
288
+ icon.className = 'dir-browser-entry-icon';
289
+ icon.textContent = entry.isGitRepo ? '\uD83D\uDCC2' : '\uD83D\uDCC1';
290
+ el.appendChild(icon);
291
+
292
+ const name = document.createElement('span');
293
+ name.className = 'dir-browser-entry-name';
294
+ name.textContent = entry.name;
295
+ el.appendChild(name);
296
+
297
+ if (entry.isGitRepo) {
298
+ const badge = document.createElement('span');
299
+ badge.className = 'dir-browser-git-badge';
300
+ badge.textContent = 'git';
301
+ el.appendChild(badge);
302
+ }
303
+
304
+ const fullPath = joinBrowsePath(data.path, entry.name);
305
+ // Single-click always navigates into directory
306
+ el.onclick = () => fetchDirectory(fullPath);
307
+ list.appendChild(el);
308
+ }
309
+ }
310
+
311
+ function setupDirBrowser() {
312
+ document.getElementById('btn-dir-browser-close').onclick = () => closeDirBrowser();
313
+ document.getElementById('btn-dir-browser-cancel').onclick = () => closeDirBrowser();
314
+ document.getElementById('btn-dir-browser-select').onclick = () => {
315
+ if (dirBrowserCurrentPath) closeDirBrowser(dirBrowserCurrentPath);
316
+ };
317
+ document.getElementById('dir-browser-modal').onclick = (e) => {
318
+ if (e.target.id === 'dir-browser-modal') closeDirBrowser();
319
+ };
320
+ }
321
+
322
+ function setupAddRepoButton() {
323
+ const bar = document.getElementById('add-repo-bar');
324
+ const input = document.getElementById('add-repo-input');
325
+ const browseBtn = document.getElementById('add-repo-browse');
326
+
327
+ document.getElementById('btn-add-repo').onclick = () => {
328
+ const visible = bar.style.display !== 'none';
329
+ bar.style.display = visible ? 'none' : 'flex';
330
+ if (!visible) {
331
+ input.value = '';
332
+ input.focus();
333
+ }
334
+ };
335
+
336
+ input.addEventListener('keydown', (e) => {
337
+ if (e.key === 'Enter') {
338
+ const path = input.value.trim();
339
+ if (path) {
340
+ send({ type: 'add-repo', path });
341
+ bar.style.display = 'none';
342
+ }
343
+ } else if (e.key === 'Escape') {
344
+ bar.style.display = 'none';
345
+ }
346
+ });
347
+
348
+ browseBtn.onclick = () => {
349
+ openDirBrowser(input.value.trim() || '', (selectedPath) => {
350
+ send({ type: 'add-repo', path: selectedPath });
351
+ bar.style.display = 'none';
352
+ });
353
+ };
354
+ }
355
+
356
+ function updateRepoDropdown() {
357
+ const repoInput = document.getElementById('spawn-repo');
358
+ const dropdown = document.getElementById('repo-dropdown');
359
+ dropdown.innerHTML = '';
360
+ const filter = repoInput.value.trim().toLowerCase();
361
+ let count = 0;
362
+ for (const [path, repo] of repos) {
363
+ if (filter && !path.toLowerCase().includes(filter) && !repo.slug.toLowerCase().includes(filter)) continue;
364
+ const item = document.createElement('div');
365
+ item.className = 'repo-dropdown-item';
366
+ item.innerHTML = `<span class="repo-dropdown-slug">${escapeHtml(repo.slug)}</span><span class="repo-dropdown-path">${escapeHtml(path)}</span>`;
367
+ item.onmousedown = (e) => {
368
+ e.preventDefault();
369
+ repoInput.value = path;
370
+ dropdown.style.display = 'none';
371
+ };
372
+ dropdown.appendChild(item);
373
+ count++;
374
+ }
375
+ dropdown.style.display = count > 0 ? 'block' : 'none';
376
+ }
377
+
378
+ // --- Dividers (N-panel layout) ---
379
+ function setupDividers() {
380
+ restorePanelWidths();
381
+ setupDivider('divider-explorer', 'explorer-panel', 'office-panel', 180, 160);
382
+ setupDivider('divider', 'office-panel', 'terminal-panel', 160, 300);
383
+ }
384
+
385
+ function savePanelWidths() {
386
+ const widths = {};
387
+ for (const id of ['explorer-panel', 'office-panel']) {
388
+ const el = document.getElementById(id);
389
+ if (el && el.style.width) widths[id] = el.style.width;
390
+ }
391
+ localStorage.setItem('agent007-panel-widths', JSON.stringify(widths));
392
+ }
393
+
394
+ function restorePanelWidths() {
395
+ try {
396
+ const saved = JSON.parse(localStorage.getItem('agent007-panel-widths'));
397
+ if (!saved) return;
398
+ for (const [id, width] of Object.entries(saved)) {
399
+ const el = document.getElementById(id);
400
+ if (el) el.style.width = width;
401
+ }
402
+ } catch {}
403
+ }
404
+
405
+ function setupDivider(dividerId, leftId, rightId, leftMin, rightMin) {
406
+ const divider = document.getElementById(dividerId);
407
+ const left = document.getElementById(leftId);
408
+ if (!divider || !left) return;
409
+
410
+ let startX, startWidth;
411
+
412
+ divider.addEventListener('mousedown', (e) => {
413
+ startX = e.clientX;
414
+ startWidth = left.offsetWidth;
415
+ divider.classList.add('dragging');
416
+ document.addEventListener('mousemove', onDrag);
417
+ document.addEventListener('mouseup', onDragEnd);
418
+ e.preventDefault();
419
+ });
420
+
421
+ function onDrag(e) {
422
+ const right = document.getElementById(rightId);
423
+ const totalAvail = left.offsetWidth + right.offsetWidth;
424
+ let newWidth = startWidth + e.clientX - startX;
425
+ newWidth = Math.max(leftMin, Math.min(totalAvail - rightMin, newWidth));
426
+ left.style.width = `${newWidth}px`;
427
+ if (leftId === 'office-panel' || rightId === 'terminal-panel') {
428
+ renderOffice();
429
+ fitActiveTerminal();
430
+ }
431
+ }
432
+
433
+ function onDragEnd() {
434
+ divider.classList.remove('dragging');
435
+ document.removeEventListener('mousemove', onDrag);
436
+ document.removeEventListener('mouseup', onDragEnd);
437
+ savePanelWidths();
438
+ }
439
+ }
440
+
441
+ // --- Resize ---
442
+ function setupResize() {
443
+ document.addEventListener('visibilitychange', fitActiveTerminal);
444
+ window.addEventListener('resize', () => {
445
+ renderOffice();
446
+ fitActiveTerminal();
447
+ });
448
+ // iOS never shrinks the layout viewport for the software keyboard (Android
449
+ // does, via interactive-widget in the viewport meta), so no resize event
450
+ // fires and the prompt line sits under the keyboard. Follow the visual
451
+ // viewport instead, on phones only and only unzoomed: it also tracks
452
+ // pinch-zoom, which would halve the body. Cleared otherwise, so a rotation
453
+ // or a widened window never keeps a stale px height.
454
+ // ponytail: no scroll compensation; add window.scrollTo(0, 0) if iOS still shoves the layout up
455
+ window.visualViewport?.addEventListener('resize', () => {
456
+ const vv = window.visualViewport;
457
+ const phone = window.matchMedia('(max-width: 700px)').matches && vv.scale === 1;
458
+ document.body.style.height = phone ? `${vv.height}px` : '';
459
+ renderOffice();
460
+ fitActiveTerminal();
461
+ });
462
+ // Phone bottom nav. The shown panel changes size from 0, so refit after reflow.
463
+ document.getElementById('mobile-nav').onclick = (e) => {
464
+ const view = e.target.dataset.view;
465
+ if (!view) return;
466
+ // The mic's only "recording" cue lives in the terminal panel.
467
+ if (view !== 'terminal') stopVoice({ notice: 'Voice input stopped — left the terminal' });
468
+ setView(view);
469
+ requestAnimationFrame(() => { renderOffice(); fitActiveTerminal(); });
470
+ };
471
+ }
472
+
473
+ // --- WS Message Router ---
474
+ function onMessage(msg) {
475
+ switch (msg.type) {
476
+ case 'welcome':
477
+ setSelf(msg.user ? msg.user.id : null, msg.authEnabled, msg.platform);
478
+ setBillionEnabled(msg.billionEnabled);
479
+ refreshSpawnPresets();
480
+ break;
481
+ case 'presence': renderPresence(msg.users, selfUserId); break;
482
+ case 'session-created':
483
+ // A genuinely new session walks in; re-emits and the connect replay don't
484
+ if (!agents.has(msg.sessionId)) noteAgentArrival(msg.sessionId);
485
+ handleSessionCreated(msg);
486
+ // Only this window's own spawn closes its form; someone else's must not
487
+ // hide a half-filled form or re-arm a Start still waiting on its reply.
488
+ if (msg.focus && window._onSessionCreatedCloseSpawn) window._onSessionCreatedCloseSpawn();
489
+ scheduleTabRestore();
490
+ break;
491
+ case 'pty-output': handlePtyOutput(msg); break;
492
+ case 'pty-size': handlePtySize(msg); break;
493
+ case 'state-change':
494
+ // A process exit broadcasts DISCONNECTED here BEFORE session-ended, so
495
+ // this alive->dead transition is where the walk-out must be captured —
496
+ // by session-ended the DISCONNECTED guard would swallow it.
497
+ if (msg.state === 'DISCONNECTED') noteAgentDeparture(msg.sessionId);
498
+ handleStateChange(msg);
499
+ break;
500
+ case 'session-ended':
501
+ // Before the handler: the walk-out needs the desk position while the
502
+ // agent is still in the map.
503
+ noteAgentDeparture(msg.sessionId);
504
+ handleSessionEnded(msg);
505
+ break;
506
+ case 'spawn-error':
507
+ handleSpawnError(msg);
508
+ if (window._onSpawnErrorInForm) window._onSpawnErrorInForm(msg.error);
509
+ break;
510
+ case 'repos-list': handleReposList(msg); break;
511
+ case 'file-tree': handleFileTree(msg); break;
512
+ case 'branch-changed': {
513
+ const agent = agents.get(msg.sessionId);
514
+ if (agent) {
515
+ agent.branchName = msg.branchName;
516
+ updateStatusBar();
517
+ renderExplorer();
518
+ }
519
+ break;
520
+ }
521
+ case 'session-renamed': {
522
+ const agent = agents.get(msg.sessionId);
523
+ if (agent) {
524
+ agent.name = msg.name;
525
+ updateTabs();
526
+ updateTopbarAgent();
527
+ renderExplorer();
528
+ renderOffice();
529
+ }
530
+ break;
531
+ }
532
+ case 'conflicts-update': handleConflictsUpdate(msg); break;
533
+ case 'file-diff': handleFileDiff(msg); break;
534
+ case 'full-tree': handleFullTree(msg); break;
535
+ case 'orphans-list': handleOrphansList(msg); break;
536
+ // The office canvas pins one paper per job, so a jobs-list broadcast has
537
+ // to repaint it too — the animation loop skips frames with no live agent.
538
+ case 'jobs-list': handleJobsList(msg); noteJobsUpdate(); renderOffice(); break;
539
+ case 'upload-complete': handleUploadComplete(msg); break;
540
+ case 'notification': handleNotification(msg); break;
541
+ case 'repo-error':
542
+ handleRepoError(msg);
543
+ explorerHandleRepoError(msg);
544
+ break;
545
+ }
546
+ }
547
+
548
+ function handleNotification(msg) {
549
+ const bar = document.getElementById('status-bar');
550
+ bar.textContent = msg.message;
551
+ bar.style.color = msg.level === 'error' ? 'var(--state-disconnected)' : 'var(--state-message)';
552
+ const duration = msg.level === 'warning' ? 6000 : 4000;
553
+ setTimeout(() => {
554
+ bar.style.color = '';
555
+ updateStatusBar();
556
+ }, duration);
557
+ }
558
+
559
+ function handleRepoError(msg) {
560
+ const bar = document.getElementById('status-bar');
561
+ bar.textContent = `Repo error: ${msg.error}`;
562
+ bar.style.color = 'var(--state-disconnected)';
563
+ setTimeout(() => {
564
+ bar.style.color = '';
565
+ updateStatusBar();
566
+ }, 4000);
567
+ }
568
+
569
+ // --- Theme Toggle ---
570
+ function setupThemePicker() {
571
+ const saved = localStorage.getItem('agent007-theme') || 'dark';
572
+ applyTheme(saved);
573
+
574
+ document.getElementById('theme-toggle').onclick = () => {
575
+ const current = localStorage.getItem('agent007-theme') || 'dark';
576
+ applyTheme(current === 'dark' ? 'light' : 'dark');
577
+ };
578
+ }
579
+
580
+ function applyTheme(theme) {
581
+ if (theme === 'light') {
582
+ document.documentElement.setAttribute('data-theme', 'light');
583
+ } else {
584
+ document.documentElement.removeAttribute('data-theme');
585
+ }
586
+ // Toggle icon: show sun in dark mode (to switch to light), moon in light mode (to switch to dark)
587
+ document.getElementById('theme-icon-sun').style.display = theme === 'dark' ? '' : 'none';
588
+ document.getElementById('theme-icon-moon').style.display = theme === 'light' ? '' : 'none';
589
+ localStorage.setItem('agent007-theme', theme);
590
+ updateTerminalThemes();
591
+ // The animation loop skips frames with no living agents, so repaint the
592
+ // office once or it keeps the previous theme's colors until an agent moves.
593
+ renderOffice();
594
+ }
595
+
596
+ // --- Active Tab Restore ---
597
+ // Capture saved tab before connect() triggers replay (which overwrites localStorage)
598
+ let savedActiveTab = localStorage.getItem('agent007-active-tab');
599
+ let restoreTimer = null;
600
+
601
+ function scheduleTabRestore() {
602
+ clearTimeout(restoreTimer);
603
+ restoreTimer = setTimeout(() => {
604
+ // Restore tab order from saved preference
605
+ try {
606
+ const savedOrder = JSON.parse(localStorage.getItem('agent007-tab-order'));
607
+ if (savedOrder && Array.isArray(savedOrder)) {
608
+ const reordered = new Map();
609
+ for (const id of savedOrder) {
610
+ if (agents.has(id)) reordered.set(id, agents.get(id));
611
+ }
612
+ for (const [id, agent] of agents) {
613
+ if (!reordered.has(id)) reordered.set(id, agent);
614
+ }
615
+ agents.clear();
616
+ for (const [id, agent] of reordered) agents.set(id, agent);
617
+ updateTabs();
618
+ }
619
+ } catch {}
620
+ // Once only, for the connect replay: a later session-created (someone
621
+ // else's spawn) must not drag this window back to its page-load tab.
622
+ if (savedActiveTab && agents.has(savedActiveTab)) {
623
+ switchToSession(savedActiveTab);
624
+ savedActiveTab = null;
625
+ }
626
+ restoreTimer = null;
627
+ }, 100);
628
+ }
629
+
630
+ // --- Init ---
631
+ async function init() {
632
+ captureTokenFromUrl();
633
+ setupThemePicker();
634
+ setupSpawnForm();
635
+ setupDirBrowser();
636
+ setupAddRepoButton();
637
+ setupDividers();
638
+ setupOfficeClick();
639
+ setupExplorer();
640
+ setupShortcuts();
641
+ setupResize();
642
+ setupUpload();
643
+ setupVoice();
644
+ setupJobBoard();
645
+ startAnimationLoop();
646
+ connect(onMessage);
647
+ renderOffice();
648
+ }
649
+
650
+ init();
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pablo De Lucca
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.