@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
@@ -0,0 +1,651 @@
1
+ // Terminal (xterm.js) lifecycle, tabs, session switching, file upload
2
+ import { agents, activeSessionId, setActiveSession, stateColor, canControlAgent, boardActive, jobs, billionFirst } from './state.js';
3
+ import { send } from './ws.js';
4
+ import { escapeHtml, safeColor } from './auth.js';
5
+ import { isGlobalShortcut } from './shortcuts.js';
6
+ import { stopVoice } from './voice.js';
7
+ import { showJobBoard, hideJobBoard } from './jobs.js';
8
+ // Circular with office.js (it imports switchToSession), which is fine: both
9
+ // sides only call the other's functions at event time, never during load.
10
+ import { noteAgentDeparture } from './office.js';
11
+
12
+ function waitForXterm() {
13
+ return new Promise((resolve) => {
14
+ if (window.Terminal) return resolve();
15
+ const check = setInterval(() => {
16
+ if (window.Terminal) { clearInterval(check); resolve(); }
17
+ }, 50);
18
+ });
19
+ }
20
+
21
+ function getTerminalTheme() {
22
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
23
+ return isLight
24
+ ? {
25
+ // GitHub-light ANSI palette on a warm paper background — the dark
26
+ // palette's cyan/green/yellow were near-invisible on light. The
27
+ // background/foreground/cursor track the CSS light-theme tokens
28
+ // (guarded by test/theme-tokens.test.js); selection is a gold-tinted
29
+ // cream chosen to stay visible against the #f2f0e5 ground.
30
+ background: '#f2f0e5', foreground: '#1c1b1a', cursor: '#7d611f', selectionBackground: '#e4d8ab',
31
+ black: '#24292f',
32
+ red: '#cf222e',
33
+ green: '#1a7f37',
34
+ yellow: '#7d4e00',
35
+ blue: '#0969da',
36
+ magenta: '#8250df',
37
+ cyan: '#1b7c83',
38
+ // white / bright entries darkened from stock GitHub-light: that
39
+ // palette assumes a #ffffff ground and loses AA on the cream one.
40
+ white: '#5c6570',
41
+ brightBlack: '#57606a',
42
+ brightRed: '#a40e26',
43
+ brightGreen: '#1a7f37',
44
+ brightYellow: '#633c01',
45
+ brightBlue: '#0969da',
46
+ brightMagenta: '#8250df',
47
+ brightCyan: '#3192aa',
48
+ brightWhite: '#24292f',
49
+ }
50
+ : {
51
+ background: '#0d1117',
52
+ foreground: '#c9d1d9',
53
+ cursor: '#7fdbca',
54
+ selectionBackground: '#264f78',
55
+ black: '#6e7681',
56
+ red: '#ff7b72',
57
+ green: '#3fb950',
58
+ yellow: '#d29922',
59
+ blue: '#58a6ff',
60
+ magenta: '#bc8cff',
61
+ cyan: '#76d9e6',
62
+ white: '#c9d1d9',
63
+ brightBlack: '#8b949e',
64
+ brightRed: '#ffa198',
65
+ brightGreen: '#56d364',
66
+ brightYellow: '#e3b341',
67
+ brightBlue: '#79c0ff',
68
+ brightMagenta: '#d2a8ff',
69
+ brightCyan: '#a5d6ff',
70
+ brightWhite: '#f0f3f6',
71
+ };
72
+ }
73
+
74
+ export function updateTerminalThemes() {
75
+ const theme = getTerminalTheme();
76
+ for (const [, agent] of agents) {
77
+ if (agent.term) agent.term.options.theme = theme;
78
+ }
79
+ }
80
+
81
+ // Callbacks set by app.js for cross-module coordination
82
+ let onSessionChanged = null;
83
+ export function setOnSessionChanged(fn) { onSessionChanged = fn; }
84
+
85
+ export async function handleSessionCreated(msg) {
86
+ await waitForXterm();
87
+ const { sessionId, name, color, command, state, repoPath, repoSlug, branchName, changedCount, additions, removals, ownerId, ownerName, ownerColor, spawnedBy, jobId, cols, rows, focus, isBillion } = msg;
88
+
89
+ if (agents.has(sessionId)) {
90
+ const a = agents.get(sessionId);
91
+ a.state = state || 'WORKING';
92
+ // Keep name and ownership fresh if the session is re-emitted (reconnect/reassignment).
93
+ a.name = name;
94
+ a.ownerId = ownerId || null;
95
+ a.ownerName = ownerName || null;
96
+ a.ownerColor = ownerColor || null;
97
+ // A reconnect missed any pty-size sent while this window was away.
98
+ if (cols && rows) handlePtySize({ sessionId, cols, rows });
99
+ updateStatusBar();
100
+ if (onSessionChanged) onSessionChanged();
101
+ return;
102
+ }
103
+
104
+ const term = new Terminal({
105
+ // The pty's size, so the replayed scrollback lands at the width it was
106
+ // drawn for. fitAgent() below then settles the size with the server.
107
+ ...(cols && rows ? { cols, rows } : {}),
108
+ theme: getTerminalTheme(),
109
+ // Agents inside the PTY (e.g. Claude Code) pick their own truecolor text
110
+ // for prompts and can't see the browser theme — dark-on-dark otherwise.
111
+ // xterm nudges any foreground below WCAG AA toward readable, live, in
112
+ // both themes, without touching colors that already pass.
113
+ minimumContrastRatio: 4.5,
114
+ fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
115
+ fontSize: 13,
116
+ cursorBlink: true,
117
+ allowProposedApi: true,
118
+ });
119
+
120
+ const termEl = document.createElement('div');
121
+ termEl.className = 'terminal-container';
122
+ termEl.style.display = 'none';
123
+ document.getElementById('terminal-viewport').appendChild(termEl);
124
+ term.open(termEl);
125
+
126
+ let fitAddon = null;
127
+ if (window.FitAddon) {
128
+ fitAddon = new FitAddon.FitAddon();
129
+ term.loadAddon(fitAddon);
130
+ }
131
+ if (window.WebLinksAddon) {
132
+ term.loadAddon(new WebLinksAddon.WebLinksAddon());
133
+ }
134
+
135
+ // Custom key handler for global shortcuts
136
+ term.attachCustomKeyEventHandler((event) => {
137
+ if (isGlobalShortcut(event)) {
138
+ return false;
139
+ }
140
+ return true;
141
+ });
142
+
143
+ // A restarted Billion replaces the stopped one's tab: the server has already
144
+ // dropped that session.
145
+ let replacesActive = false;
146
+ if (isBillion) {
147
+ for (const [id, a] of [...agents]) {
148
+ if (!a.isBillion) continue;
149
+ if (id === activeSessionId) replacesActive = true;
150
+ disposeAgent(id);
151
+ }
152
+ }
153
+
154
+ agents.set(sessionId, {
155
+ name, color, command, fitAddon,
156
+ isBillion: !!isBillion,
157
+ state: state || 'WORKING',
158
+ term, termEl,
159
+ ownerId: ownerId || null,
160
+ ownerName: ownerName || null,
161
+ ownerColor: ownerColor || null,
162
+ repoPath: repoPath || null,
163
+ repoSlug: repoSlug || null,
164
+ branchName: branchName || null,
165
+ changedCount: changedCount || 0,
166
+ additions: additions || 0,
167
+ removals: removals || 0,
168
+ fileTree: null,
169
+ conflicts: [],
170
+ spawnedBy: spawnedBy || 'user',
171
+ jobId: jobId || null,
172
+ // Local mirror of the server's lastOutputAt, maintained in handlePtyOutput.
173
+ // The job board uses it to tell "still working" from "parked at a prompt,
174
+ // probably waiting on a human" without any extra server traffic.
175
+ lastOutputAt: Date.now(),
176
+ });
177
+
178
+ requestAnimationFrame(() => fitAgent(sessionId));
179
+ // Showing or hiding a terminal (tab switch, job board, phone views, panel
180
+ // drags) changes its size, so this one place keeps the server told which
181
+ // terminal this window is showing, or that it shows none.
182
+ if (window.ResizeObserver) {
183
+ new ResizeObserver(() => { if (activeSessionId === sessionId) fitActiveTerminal(); }).observe(termEl);
184
+ }
185
+
186
+ term.onData((data) => {
187
+ // Read-only for non-owners: don't forward keystrokes (server enforces too).
188
+ if (!canControlAgent(agents.get(sessionId))) return;
189
+ send({ type: 'pty-input', sessionId, data });
190
+ });
191
+
192
+ // Only the window that spawned or adopted this agent switches to it (the
193
+ // server marks that copy `focus`). Board dispatches and other people's
194
+ // spawns open quietly — the tab dot, the office character and the job card
195
+ // still announce them, and clicking any of them jumps here. A window showing
196
+ // nothing takes it, unless that nothing is the job board being worked on.
197
+ const stealFocus = focus || replacesActive || (!activeSessionId && !boardActive);
198
+ if (stealFocus) switchToSession(sessionId);
199
+ updateTabs();
200
+ updateStatusBar();
201
+ document.getElementById('office-empty').style.display = 'none';
202
+ if (onSessionChanged) onSessionChanged();
203
+ }
204
+
205
+ export function handlePtyOutput(msg) {
206
+ const agent = agents.get(msg.sessionId);
207
+ if (!agent) return;
208
+ const bytes = Uint8Array.from(atob(msg.data), c => c.charCodeAt(0));
209
+ agent.lastOutputAt = Date.now();
210
+ agent.term.write(bytes);
211
+ }
212
+
213
+ // Tell the server how big this window could show the terminal. The pty takes
214
+ // the smallest size among the owner's windows showing it (ws.js
215
+ // fitPtyToWatchers) and answers with pty-size, so the copy here is only ever
216
+ // resized to that, never to this window alone. A view-only window reports
217
+ // too, which is how the server learns it moved off a terminal it owns.
218
+ function fitAgent(sessionId) {
219
+ const agent = agents.get(sessionId);
220
+ if (!agent || agent.termEl.offsetWidth === 0) return;
221
+ // Output written while hidden leaves xterm's scroll area short, so the
222
+ // wheel stops rows above the prompt. Every show passes through here.
223
+ // ponytail: private xterm 5.5 API; test/client-pty-size pins the version
224
+ agent.term._core?.viewport?.syncScrollArea?.(true);
225
+ if (!agent.fitAddon) return;
226
+ const dims = agent.fitAddon.proposeDimensions();
227
+ if (dims && dims.cols > 0 && dims.rows > 0) send({ type: 'pty-resize', sessionId, cols: dims.cols, rows: dims.rows });
228
+ }
229
+
230
+ // Render at the pty's size, not this window's, so its output is never
231
+ // reflowed into a width it wasn't drawn for.
232
+ export function handlePtySize(msg) {
233
+ const agent = agents.get(msg.sessionId);
234
+ if (agent && (agent.term.cols !== msg.cols || agent.term.rows !== msg.rows)) agent.term.resize(msg.cols, msg.rows);
235
+ }
236
+
237
+ export function handleStateChange(msg) {
238
+ const agent = agents.get(msg.sessionId);
239
+ if (!agent) return;
240
+ agent.state = msg.state;
241
+ updateTabs();
242
+ updateStatusBar();
243
+ if (onSessionChanged) onSessionChanged();
244
+ }
245
+
246
+ export function handleSpawnError(msg) {
247
+ const bar = document.getElementById('status-bar');
248
+ bar.textContent = `Error: ${msg.error}`;
249
+ bar.style.color = 'var(--state-disconnected)';
250
+ setTimeout(() => {
251
+ bar.style.color = '';
252
+ updateStatusBar();
253
+ }, 4000);
254
+ }
255
+
256
+ export function handleSessionEnded(msg) {
257
+ const agent = agents.get(msg.sessionId);
258
+ if (!agent) return;
259
+ // The server drops input to an exited session — keeping the mic hot here
260
+ // would transcribe speech into a dead pty forever.
261
+ if (msg.sessionId === activeSessionId) stopVoice({ notice: 'Voice input stopped — agent ended' });
262
+ agent.state = 'DISCONNECTED';
263
+
264
+ // A board agent retired after opening its PR takes its tab with it. Under
265
+ // unattended dispatch these arrive steadily, and a row of dead tabs is pure
266
+ // clutter — the job card still carries the agent name, branch and PR link.
267
+ // Agents you spawned yourself keep their tab, as before, so you can read the
268
+ // output and close it when you're ready.
269
+ if (agent.spawnedBy === 'board' && agent.jobId) {
270
+ disposeAgent(msg.sessionId);
271
+ return;
272
+ }
273
+
274
+ updateTabs();
275
+ updateStatusBar();
276
+ if (onSessionChanged) onSessionChanged();
277
+ }
278
+
279
+ // Tear down a session's terminal locally. Unlike removeSession() this sends no
280
+ // 'kill' — the process is already gone; this only reclaims the client's UI.
281
+ function disposeAgent(sessionId) {
282
+ const agent = agents.get(sessionId);
283
+ if (!agent) return;
284
+ agent.term.dispose();
285
+ agent.termEl.remove();
286
+ agents.delete(sessionId);
287
+ if (activeSessionId === sessionId) {
288
+ setActiveSession(null);
289
+ const remaining = [...agents.keys()];
290
+ if (remaining.length > 0) {
291
+ switchToSession(remaining[remaining.length - 1]);
292
+ } else {
293
+ // Nothing left to show. The board is the sensible landing spot: it is
294
+ // what dispatched this agent and it explains where the work went. The
295
+ // office still needs its empty state though — removeSession() sets this
296
+ // too, and without it the office renders a room with no one in it and no
297
+ // explanation.
298
+ document.getElementById('office-empty').style.display = 'flex';
299
+ showJobBoard();
300
+ }
301
+ }
302
+ updateTabs();
303
+ updateStatusBar();
304
+ if (onSessionChanged) onSessionChanged();
305
+ }
306
+
307
+ export function switchToSession(sessionId) {
308
+ // Dictation targets the active session — don't let speech begun for one
309
+ // agent land in another's shell after a tab switch (or auto-switch on kill).
310
+ if (sessionId !== activeSessionId) stopVoice({ notice: 'Voice input stopped — switched agents' });
311
+ hideJobBoard();
312
+ if (activeSessionId && agents.has(activeSessionId)) {
313
+ agents.get(activeSessionId).termEl.style.display = 'none';
314
+ }
315
+ setActiveSession(sessionId);
316
+ const agent = agents.get(sessionId);
317
+ if (!agent) return;
318
+ agent.termEl.style.display = 'block';
319
+ localStorage.setItem('agent007-active-tab', sessionId);
320
+ document.getElementById('terminal-empty').style.display = 'none';
321
+ // Double rAF ensures browser has reflowed after display change
322
+ requestAnimationFrame(() => {
323
+ requestAnimationFrame(() => {
324
+ fitAgent(sessionId);
325
+ agent.term.scrollToBottom();
326
+ agent.term.focus();
327
+ });
328
+ });
329
+ updateTabs();
330
+ updateTopbarAgent();
331
+ if (onSessionChanged) onSessionChanged();
332
+ }
333
+
334
+ export function removeSession(sessionId) {
335
+ const agent = agents.get(sessionId);
336
+ if (!agent) return;
337
+ // Confirm before closing agent with a repo (unsaved work will be orphaned)
338
+ if (agent.repoPath && agent.state !== 'DISCONNECTED') {
339
+ if (!confirm(`Close ${agent.name}? Unsaved work will be kept as an orphan.`)) return;
340
+ }
341
+ // Closing the last session never reaches switchToSession, so its stopVoice
342
+ // guard would be bypassed and the mic would stay hot over the empty state.
343
+ if (sessionId === activeSessionId) stopVoice({ notice: 'Voice input stopped — agent closed' });
344
+ if (agent.state !== 'DISCONNECTED') {
345
+ noteAgentDeparture(sessionId); // walk out before the tile disappears
346
+ send({ type: 'kill', sessionId });
347
+ }
348
+ agent.term.dispose();
349
+ agent.termEl.remove();
350
+ agents.delete(sessionId);
351
+ if (activeSessionId === sessionId) {
352
+ setActiveSession(null);
353
+ const remaining = [...agents.keys()];
354
+ if (remaining.length > 0) {
355
+ switchToSession(remaining[remaining.length - 1]);
356
+ } else {
357
+ document.getElementById('terminal-empty').style.display = 'flex';
358
+ document.getElementById('office-empty').style.display = 'flex';
359
+ }
360
+ }
361
+ updateTabs();
362
+ updateStatusBar();
363
+ if (onSessionChanged) onSessionChanged();
364
+ }
365
+
366
+ export function updateTabs() {
367
+ const container = document.getElementById('terminal-tabs');
368
+ container.innerHTML = '';
369
+
370
+ // Pinned board tab: always first, never draggable, never closable. It has no
371
+ // data-session-id, so the drag-reorder handler below skips it automatically.
372
+ const boardTab = document.createElement('div');
373
+ boardTab.className = `terminal-tab board-tab${boardActive ? ' active' : ''}`;
374
+ boardTab.title = 'Job board';
375
+ const attention = [...jobs.values()].filter((j) => {
376
+ if (j.state !== 'in-progress' || !j.agentSessionId) return false;
377
+ const a = agents.get(j.agentSessionId);
378
+ return !a || a.state === 'MESSAGE' || a.state === 'DISCONNECTED';
379
+ }).length;
380
+ boardTab.innerHTML = '<svg class="board-tab-icon" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="1" y="1.5" width="2.6" height="9"/><rect x="4.7" y="1.5" width="2.6" height="6"/><rect x="8.4" y="1.5" width="2.6" height="7.5"/></svg>';
381
+ boardTab.appendChild(document.createTextNode('Jobs'));
382
+ if (attention > 0) {
383
+ const badge = document.createElement('span');
384
+ badge.className = 'board-tab-badge';
385
+ badge.textContent = String(attention);
386
+ badge.title = `${attention} job${attention === 1 ? '' : 's'} need attention`;
387
+ boardTab.appendChild(badge);
388
+ }
389
+ boardTab.onclick = () => { showJobBoard(); updateTabs(); };
390
+ container.appendChild(boardTab);
391
+
392
+ for (const [sessionId, agent] of billionFirst(agents)) {
393
+ const tab = document.createElement('div');
394
+ tab.className = `terminal-tab${sessionId === activeSessionId ? ' active' : ''}`;
395
+ // Billion's tab stays first, and its name is fixed.
396
+ tab.draggable = !agent.isBillion;
397
+ tab.dataset.sessionId = sessionId;
398
+ tab.onclick = (e) => {
399
+ if (!e.target.classList.contains('close-btn') && !e.target.classList.contains('upload-btn')) switchToSession(sessionId);
400
+ };
401
+ if (canControlAgent(agent) && !agent.isBillion) {
402
+ tab.title = 'Double-click to rename';
403
+ tab.ondblclick = () => promptRename(sessionId);
404
+ }
405
+ tab.ondragstart = (e) => {
406
+ e.dataTransfer.setData('text/plain', sessionId);
407
+ tab.classList.add('dragging');
408
+ };
409
+ tab.ondragend = () => { tab.classList.remove('dragging'); };
410
+ tab.ondragover = (e) => {
411
+ e.preventDefault();
412
+ const dragging = container.querySelector('.dragging');
413
+ if (dragging && dragging !== tab) {
414
+ const rect = tab.getBoundingClientRect();
415
+ const mid = rect.left + rect.width / 2;
416
+ if (e.clientX < mid) {
417
+ container.insertBefore(dragging, tab);
418
+ } else {
419
+ container.insertBefore(dragging, tab.nextSibling);
420
+ }
421
+ }
422
+ };
423
+ tab.ondrop = (e) => {
424
+ e.preventDefault();
425
+ // Rebuild agents Map in new tab order
426
+ const tabs = [...container.querySelectorAll('.terminal-tab[data-session-id]')];
427
+ const newOrder = new Map();
428
+ for (const t of tabs) {
429
+ const id = t.dataset.sessionId;
430
+ if (agents.has(id)) newOrder.set(id, agents.get(id));
431
+ }
432
+ // Add any agents not in tabs (shouldn't happen but be safe)
433
+ for (const [id, agent] of agents) {
434
+ if (!newOrder.has(id)) newOrder.set(id, agent);
435
+ }
436
+ agents.clear();
437
+ for (const [id, agent] of newOrder) agents.set(id, agent);
438
+ localStorage.setItem('agent007-tab-order', JSON.stringify([...agents.keys()]));
439
+ if (onSessionChanged) onSessionChanged();
440
+ };
441
+ const dot = document.createElement('span');
442
+ dot.className = 'dot';
443
+ dot.style.background = stateColor(agent.state);
444
+ tab.appendChild(dot);
445
+ if (agent.spawnedBy === 'board') tab.classList.add('board-spawned');
446
+ tab.appendChild(document.createTextNode(agent.name));
447
+ const close = document.createElement('span');
448
+ close.className = 'close-btn';
449
+ close.title = 'Close';
450
+ close.textContent = '\u00d7';
451
+ close.onclick = (e) => { e.stopPropagation(); removeSession(sessionId); };
452
+ tab.appendChild(close);
453
+ container.appendChild(tab);
454
+ }
455
+ }
456
+
457
+ // Shared by the tab double-click and the topbar button (the keyboard path).
458
+ export function promptRename(sessionId) {
459
+ const agent = agents.get(sessionId);
460
+ if (!agent) return;
461
+ const name = prompt('Rename agent', agent.name);
462
+ if (name && name.trim() && name.trim() !== agent.name) send({ type: 'rename-session', sessionId, name: name.trim() });
463
+ }
464
+
465
+ export function updateTopbarAgent() {
466
+ const el = document.getElementById('topbar-agent-info');
467
+ if (!el) return;
468
+ if (!activeSessionId) {
469
+ el.innerHTML = '';
470
+ return;
471
+ }
472
+ const agent = activeSessionId ? agents.get(activeSessionId) : null;
473
+ // Read-only visual treatment, recomputed here so it stays fresh even if identity
474
+ // (welcome) or ownership arrives after the tab was switched.
475
+ const panel = document.getElementById('terminal-panel');
476
+ if (panel) panel.classList.toggle('readonly', !!agent && !canControlAgent(agent));
477
+ if (!agent) { el.innerHTML = ''; return; }
478
+ const parts = [];
479
+ // Read-only badge when viewing an agent you don't own (phase 3).
480
+ if (!canControlAgent(agent)) {
481
+ const c = safeColor(agent.ownerColor);
482
+ const who = agent.ownerName ? ` · ${escapeHtml(agent.ownerName)}` : '';
483
+ parts.push(`<span class="topbar-readonly" style="color:${c};border-color:${c}">\u{1F441} view-only${who}</span>`);
484
+ }
485
+ if (canControlAgent(agent)) {
486
+ parts.push(`<button class="icon-btn topbar-rename" title="Rename agent" aria-label="Rename agent"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M8.5 1.5l2 2L4 10H2V8z"/></svg></button>`);
487
+ }
488
+ if (agent.repoSlug) {
489
+ parts.push(`<span class="topbar-repo-label">Repo:</span> <span class="topbar-repo-name">${escapeHtml(agent.repoSlug)}</span>`);
490
+ }
491
+ if (agent.branchName) {
492
+ parts.push(`<svg class="topbar-branch-icon" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1"><circle cx="3.5" cy="2.5" r="1.2"/><circle cx="3.5" cy="9.5" r="1.2"/><circle cx="8.5" cy="4" r="1.2"/><path d="M3.5 3.7v4.6"/><path d="M8.5 5.2c0 2.2-1.8 2.6-3.4 3.1"/></svg><span class="topbar-branch-name">${escapeHtml(agent.branchName)}</span>`);
493
+ }
494
+ el.innerHTML = parts.join(' ');
495
+ const rename = el.querySelector('.topbar-rename');
496
+ if (rename) rename.onclick = () => promptRename(activeSessionId);
497
+ }
498
+
499
+ export function updateStatusBar() {
500
+ const bar = document.getElementById('status-bar');
501
+ const count = agents.size;
502
+ const needsAttention = [...agents.values()].filter(a => a.state === 'MESSAGE').length;
503
+ if (count === 0) {
504
+ bar.textContent = 'No agents running';
505
+ } else {
506
+ let text = `${count} agent${count !== 1 ? 's' : ''} running`;
507
+ if (needsAttention > 0) {
508
+ text += ` \u00b7 ${needsAttention} need${needsAttention !== 1 ? '' : 's'} attention`;
509
+ }
510
+ bar.textContent = text;
511
+ }
512
+ updateTopbarAgent();
513
+ }
514
+
515
+ // Report the terminal on screen, or that none is: a window on the job board,
516
+ // another phone view or a background browser tab must not hold a terminal at
517
+ // its size.
518
+ export function fitActiveTerminal() {
519
+ const agent = activeSessionId && agents.get(activeSessionId);
520
+ if (agent && agent.termEl.offsetWidth > 0 && !document.hidden) fitAgent(activeSessionId);
521
+ else send({ type: 'pty-resize', sessionId: null });
522
+ }
523
+
524
+ // --- File Upload ---
525
+
526
+ function uploadFiles(files, sessionId) {
527
+ for (const file of files) {
528
+ if (file.size > 10 * 1024 * 1024) {
529
+ showUploadNotification(`${file.name} too large (max 10MB)`, true);
530
+ continue;
531
+ }
532
+ const reader = new FileReader();
533
+ reader.onload = () => {
534
+ const base64 = reader.result.split(',')[1];
535
+ send({ type: 'upload-file', sessionId, filename: file.name, data: base64 });
536
+ };
537
+ reader.readAsDataURL(file);
538
+ }
539
+ }
540
+
541
+ function showUploadNotification(message, isError) {
542
+ const bar = document.getElementById('status-bar');
543
+ bar.textContent = message;
544
+ bar.style.color = isError ? 'var(--state-disconnected)' : 'var(--state-message)';
545
+ setTimeout(() => { bar.style.color = ''; updateStatusBar(); }, 3000);
546
+ }
547
+
548
+ export function handleUploadComplete(msg) {
549
+ showUploadNotification(`Uploaded: ${msg.path}`, false);
550
+ }
551
+
552
+ export function setupUpload() {
553
+ setupClipboardPaste();
554
+ const viewport = document.getElementById('terminal-viewport');
555
+ if (!viewport) return;
556
+
557
+ // Drag-and-drop overlay
558
+ const overlay = document.createElement('div');
559
+ overlay.className = 'upload-drop-overlay';
560
+ overlay.textContent = 'Drop files to upload';
561
+ overlay.style.display = 'none';
562
+ viewport.appendChild(overlay);
563
+
564
+ let dragCounter = 0;
565
+
566
+ viewport.addEventListener('dragenter', (e) => {
567
+ e.preventDefault();
568
+ dragCounter++;
569
+ if (activeSessionId && agents.get(activeSessionId)?.repoPath) {
570
+ overlay.style.display = 'flex';
571
+ }
572
+ });
573
+
574
+ viewport.addEventListener('dragover', (e) => {
575
+ e.preventDefault();
576
+ e.dataTransfer.dropEffect = 'copy';
577
+ });
578
+
579
+ viewport.addEventListener('dragleave', (e) => {
580
+ e.preventDefault();
581
+ dragCounter--;
582
+ if (dragCounter <= 0) {
583
+ dragCounter = 0;
584
+ overlay.style.display = 'none';
585
+ }
586
+ });
587
+
588
+ viewport.addEventListener('drop', (e) => {
589
+ e.preventDefault();
590
+ dragCounter = 0;
591
+ overlay.style.display = 'none';
592
+ if (!activeSessionId) return;
593
+ const agent = agents.get(activeSessionId);
594
+ if (!agent || !agent.repoPath) {
595
+ showUploadNotification('Upload requires an agent with a repo', true);
596
+ return;
597
+ }
598
+ if (e.dataTransfer.files.length > 0) {
599
+ uploadFiles(e.dataTransfer.files, activeSessionId);
600
+ }
601
+ });
602
+ }
603
+
604
+ function setupClipboardPaste() {
605
+ // Use capture phase to intercept before xterm.js handles paste
606
+ document.addEventListener('paste', (e) => {
607
+ if (!activeSessionId) return;
608
+ // A screenshot pasted into the job form is a card attachment, not an
609
+ // upload to whichever agent happens to be selected.
610
+ if (e.target.closest?.('#job-form')) return;
611
+ const items = e.clipboardData?.items;
612
+ if (!items) return;
613
+ for (const item of items) {
614
+ if (item.type.startsWith('image/')) {
615
+ e.preventDefault();
616
+ const agent = agents.get(activeSessionId);
617
+ if (!agent || !agent.repoPath) {
618
+ showUploadNotification('Paste requires an agent with a repo', true);
619
+ return;
620
+ }
621
+ const blob = item.getAsFile();
622
+ if (!blob) return;
623
+ const ext = item.type.split('/')[1] === 'png' ? 'png' : 'jpg';
624
+ const filename = `screenshot-${Date.now()}.${ext}`;
625
+ const reader = new FileReader();
626
+ reader.onload = () => {
627
+ const base64 = reader.result.split(',')[1];
628
+ send({ type: 'upload-file', sessionId: activeSessionId, filename, data: base64 });
629
+ };
630
+ reader.readAsDataURL(blob);
631
+ return;
632
+ }
633
+ }
634
+ }, true);
635
+ }
636
+
637
+ export function triggerUpload() {
638
+ if (!activeSessionId) return;
639
+ const agent = agents.get(activeSessionId);
640
+ if (!agent || !agent.repoPath) {
641
+ showUploadNotification('Upload requires an agent with a repo', true);
642
+ return;
643
+ }
644
+ const input = document.createElement('input');
645
+ input.type = 'file';
646
+ input.multiple = true;
647
+ input.onchange = () => {
648
+ if (input.files.length > 0) uploadFiles(input.files, activeSessionId);
649
+ };
650
+ input.click();
651
+ }