@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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- 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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|