@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
|
@@ -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
|
+
}
|