@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/server/ws.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// WebSocket — connection management, message routing, broadcast
|
|
2
|
+
|
|
3
|
+
import { existsSync, rmSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import { basename, join } from 'path';
|
|
5
|
+
import {
|
|
6
|
+
config, sessions, orphans, adoptingOrphans,
|
|
7
|
+
codenamePool, colorCycler, nextSessionId,
|
|
8
|
+
GIT_USER_TIMEOUT, isAllowedOrigin,
|
|
9
|
+
} from './state.js';
|
|
10
|
+
import { authEnabled, resolveToken, tokenFromRequest, publicUser, userById, loadUsers, WS_UNAUTHORIZED } from './auth.js';
|
|
11
|
+
import { saveActiveSession, syncOrphansToConfig, saveConfig } from './config.js';
|
|
12
|
+
import { addRepo, removeRepo, scanFileTree, startTreeScanLoop, getDiff, broadcastReposList, gitExec, deleteBranch } from './git.js';
|
|
13
|
+
import { createSessionFromConfig } from './pty.js';
|
|
14
|
+
import { isTyping } from './messages.js';
|
|
15
|
+
import { parseGitStatus, buildFileTree, safeFilename } from '../lib/helpers.js';
|
|
16
|
+
import { isValidJobAgent } from '../lib/jobs.js';
|
|
17
|
+
import { billionRuns } from './billion.js';
|
|
18
|
+
import {
|
|
19
|
+
addJob, updateJob, deleteJob, moveJob, updateSettings, setJobPaused,
|
|
20
|
+
jobsPayload, broadcastJobs, runScan, relinkSessionToJob, allJobs,
|
|
21
|
+
orphanResumePlan,
|
|
22
|
+
} from './jobs.js';
|
|
23
|
+
|
|
24
|
+
// --- Client tracking ---
|
|
25
|
+
const clients = new Set();
|
|
26
|
+
|
|
27
|
+
export function broadcast(message) {
|
|
28
|
+
const data = JSON.stringify(message);
|
|
29
|
+
for (const ws of clients) {
|
|
30
|
+
if (ws.readyState === 1) ws.send(data);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Everyone gets the new tab, but only the window that asked for it switches to
|
|
35
|
+
// it — otherwise one person's spawn yanks every other browser off their tab.
|
|
36
|
+
function announceSession(session, requester) {
|
|
37
|
+
const data = JSON.stringify(sessionPayload(session));
|
|
38
|
+
const mine = JSON.stringify({ ...sessionPayload(session), focus: true });
|
|
39
|
+
for (const ws of clients) {
|
|
40
|
+
if (ws.readyState === 1) ws.send(ws === requester ? mine : data);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sessionPayload(session) {
|
|
45
|
+
const owner = userById(session.ownerId);
|
|
46
|
+
return {
|
|
47
|
+
type: 'session-created',
|
|
48
|
+
sessionId: session.id,
|
|
49
|
+
name: session.name,
|
|
50
|
+
color: session.color,
|
|
51
|
+
command: session.command,
|
|
52
|
+
state: session.state,
|
|
53
|
+
repoPath: session.repoPath,
|
|
54
|
+
repoSlug: session.repoSlug,
|
|
55
|
+
branchName: session.branchName,
|
|
56
|
+
changedCount: session.changedCount,
|
|
57
|
+
additions: session.additions || 0,
|
|
58
|
+
removals: session.removals || 0,
|
|
59
|
+
ownerId: session.ownerId || null,
|
|
60
|
+
ownerName: owner ? owner.displayName : null,
|
|
61
|
+
ownerColor: owner ? owner.color : null,
|
|
62
|
+
spawnedBy: session.spawnedBy || 'user',
|
|
63
|
+
jobId: session.jobId || null,
|
|
64
|
+
isBillion: !!session.isBillion,
|
|
65
|
+
// So a client builds its xterm at the pty's size before the scrollback
|
|
66
|
+
// replay lands, instead of reflowing it into xterm's default 80x24.
|
|
67
|
+
cols: session.pty.cols,
|
|
68
|
+
rows: session.pty.rows,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Every window renders at the size it is sent, so one client's report reaches
|
|
73
|
+
// every browser: far past any real screen, and xterm allocates cols x rows.
|
|
74
|
+
const MAX_PTY_COLS = 1000;
|
|
75
|
+
const MAX_PTY_ROWS = 500;
|
|
76
|
+
|
|
77
|
+
// A pty has one size, and every window showing it gets the same bytes. So it
|
|
78
|
+
// takes the smallest of the owner's windows showing it (as tmux does): each
|
|
79
|
+
// sees the whole screen at the right width, a bigger one with some empty
|
|
80
|
+
// space. A view-only window has no say. Every window is told the size when it
|
|
81
|
+
// changes and renders its copy at exactly that.
|
|
82
|
+
function fitPtyToWatchers(session) {
|
|
83
|
+
let cols = Infinity, rows = Infinity;
|
|
84
|
+
for (const ws of clients) {
|
|
85
|
+
const w = ws.watching;
|
|
86
|
+
if (ws.readyState === 1 && w && w.sessionId === session.id && owns(ws, session.ownerId)) {
|
|
87
|
+
cols = Math.min(cols, w.cols);
|
|
88
|
+
rows = Math.min(rows, w.rows);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (cols === Infinity) return; // no owner window is showing it: keep the last size
|
|
92
|
+
if (cols === session.pty.cols && rows === session.pty.rows) return;
|
|
93
|
+
try {
|
|
94
|
+
session.pty.resize(cols, rows);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// The pty can die a moment before onExit marks the session exited, and
|
|
97
|
+
// this runs from the socket close handler too, outside any try.
|
|
98
|
+
console.warn(`pty resize failed for ${session.id}: ${err.message}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
session.lastResizeAt = Date.now();
|
|
102
|
+
broadcast({ type: 'pty-size', sessionId: session.id, cols, rows });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function broadcastOrphansList() {
|
|
106
|
+
broadcast({ type: 'orphans-list', orphans: [...orphans.values()] });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Presence (phase 1) ---
|
|
110
|
+
// The distinct set of authenticated users currently connected. Empty when auth
|
|
111
|
+
// is disabled (no identities to report).
|
|
112
|
+
export function broadcastPresence() {
|
|
113
|
+
const seen = new Map();
|
|
114
|
+
for (const ws of clients) {
|
|
115
|
+
if (ws.readyState === 1 && ws.user) seen.set(ws.user.id, publicUser(ws.user));
|
|
116
|
+
}
|
|
117
|
+
broadcast({ type: 'presence', users: [...seen.values()] });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- WebSocket origin check (B3) ---
|
|
121
|
+
// verifyClient rejects the handshake for disallowed origins. localhost is always
|
|
122
|
+
// allowed; add remote hostnames via ALLOWED_ORIGINS (see server/state.js).
|
|
123
|
+
// Requests with no Origin header are allowed (same-origin or non-browser).
|
|
124
|
+
export function verifyClient({ origin }) {
|
|
125
|
+
return isAllowedOrigin(origin);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Ownership (phase 2) ---
|
|
129
|
+
// Only the owner may control a session/orphan. When auth is disabled (single-
|
|
130
|
+
// player) or the item is unowned (spawned before auth existed), anyone may.
|
|
131
|
+
//
|
|
132
|
+
// Two rejection shapes for non-owners, by design:
|
|
133
|
+
// - high-frequency streaming (pty-input, pty-resize): silently drop, so a
|
|
134
|
+
// read-only viewer's keystrokes don't spam a notification per character.
|
|
135
|
+
// - discrete user actions (kill, upload-file, refresh-tree, orphan
|
|
136
|
+
// re-adopt/delete): call denyControl() to surface a read-only notice.
|
|
137
|
+
// Match this when wiring any new gated message.
|
|
138
|
+
function owns(ws, ownerId) {
|
|
139
|
+
if (!authEnabled()) return true;
|
|
140
|
+
if (!ownerId) return true;
|
|
141
|
+
return !!(ws.user && ws.user.id === ownerId);
|
|
142
|
+
}
|
|
143
|
+
function denyControl(ws, name, ownerId) {
|
|
144
|
+
const owner = userById(ownerId);
|
|
145
|
+
ws.send(JSON.stringify({
|
|
146
|
+
type: 'notification', level: 'error',
|
|
147
|
+
message: `Read-only — ${name} is owned by ${owner ? owner.displayName : 'someone else'}`,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Setup ---
|
|
152
|
+
export function setupWebSocket(wss, { createSession, killSession, startBillion }) {
|
|
153
|
+
wss.on('connection', (ws, req) => {
|
|
154
|
+
// Auth gate (phase 1): when users are configured, require a valid token
|
|
155
|
+
// (?token= on the WS URL, since browsers can't set handshake headers).
|
|
156
|
+
// Close code 4401 tells the client to prompt for a token.
|
|
157
|
+
const enabled = authEnabled();
|
|
158
|
+
const user = enabled ? resolveToken(tokenFromRequest(req)) : null;
|
|
159
|
+
if (enabled && !user) {
|
|
160
|
+
try { ws.close(WS_UNAUTHORIZED, 'Unauthorized'); } catch {}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
ws.user = user; // null when auth is disabled
|
|
164
|
+
clients.add(ws);
|
|
165
|
+
|
|
166
|
+
// Tell the client who it is and whether auth is on.
|
|
167
|
+
// platform lets the client offer the right shell preset (bash vs PowerShell).
|
|
168
|
+
ws.send(JSON.stringify({ type: 'welcome', authEnabled: enabled, user: publicUser(user), platform: process.platform, billionEnabled: billionRuns() }));
|
|
169
|
+
|
|
170
|
+
// Send repos list
|
|
171
|
+
ws.send(JSON.stringify({
|
|
172
|
+
type: 'repos-list',
|
|
173
|
+
repos: config.repos.map(r => ({ path: r.path, slug: basename(r.path), exists: existsSync(r.path) })),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
// Send existing sessions, Billion first: a window that has not picked a
|
|
177
|
+
// tab opens the first one it is sent, and Billion is the default.
|
|
178
|
+
const replay = [...sessions.values()].sort((a, b) => Number(!!b.isBillion) - Number(!!a.isBillion));
|
|
179
|
+
for (const session of replay) {
|
|
180
|
+
ws.send(JSON.stringify(sessionPayload(session)));
|
|
181
|
+
const chunks = session.ringBuffer.getAll();
|
|
182
|
+
for (let i = 0; i < chunks.length; i += 100) {
|
|
183
|
+
const batch = chunks.slice(i, i + 100).join('');
|
|
184
|
+
ws.send(JSON.stringify({ type: 'pty-output', sessionId: session.id, data: Buffer.from(batch).toString('base64') }));
|
|
185
|
+
}
|
|
186
|
+
if (session.fileTree && session.fileTree.length > 0) {
|
|
187
|
+
ws.send(JSON.stringify({
|
|
188
|
+
type: 'file-tree', sessionId: session.id,
|
|
189
|
+
tree: buildFileTree(session.fileTree, basename(session.repoPath)),
|
|
190
|
+
files: session.fileTree, changedCount: session.changedCount,
|
|
191
|
+
additions: session.additions || 0, removals: session.removals || 0,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ws.send(JSON.stringify({ type: 'orphans-list', orphans: [...orphans.values()] }));
|
|
197
|
+
ws.send(JSON.stringify(jobsPayload()));
|
|
198
|
+
|
|
199
|
+
broadcastPresence();
|
|
200
|
+
|
|
201
|
+
ws.on('message', async (raw) => {
|
|
202
|
+
let msg;
|
|
203
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
204
|
+
|
|
205
|
+
// Re-check auth per message (connect-time gating alone misses two cases):
|
|
206
|
+
// - a socket that connected while auth was disabled, then auth was enabled
|
|
207
|
+
// - a user removed from users.json while their socket is still open
|
|
208
|
+
// Both must lose access without waiting for a voluntary reconnect.
|
|
209
|
+
if (authEnabled() && !(ws.user && loadUsers().some(u => u.id === ws.user.id))) {
|
|
210
|
+
try { ws.close(WS_UNAUTHORIZED, 'Unauthorized'); } catch {}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
switch (msg.type) {
|
|
216
|
+
case 'spawn': {
|
|
217
|
+
const result = await createSession(
|
|
218
|
+
msg.command || 'claude', msg.name, msg.repoPath || null, msg.branch || null,
|
|
219
|
+
ws.user ? ws.user.id : null,
|
|
220
|
+
// Optional: branch from somewhere other than the repo's base branch.
|
|
221
|
+
{ startPoint: typeof msg.startPoint === 'string' && msg.startPoint.trim() ? msg.startPoint.trim() : null },
|
|
222
|
+
);
|
|
223
|
+
if (result.error) {
|
|
224
|
+
ws.send(JSON.stringify({ type: 'spawn-error', command: msg.command || 'claude', error: result.error }));
|
|
225
|
+
} else if (result.session) {
|
|
226
|
+
announceSession(result.session, ws);
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'pty-input': {
|
|
231
|
+
const session = sessions.get(msg.sessionId);
|
|
232
|
+
// Non-owners are read-only: silently drop input (no per-keystroke error).
|
|
233
|
+
// Billion belongs to no one, so with user accounts nobody may type
|
|
234
|
+
// into it — not even in the second before the check stops it.
|
|
235
|
+
if (session && !session.exited && owns(ws, session.ownerId) && !(session.isBillion && authEnabled())) {
|
|
236
|
+
// Holds agent messages back while a person is mid-line (messages.js).
|
|
237
|
+
if (isTyping(msg.data)) session.lastUserInputAt = Date.now();
|
|
238
|
+
session.pty.write(msg.data);
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'pty-resize': {
|
|
243
|
+
// A window reports the terminal it is showing and how much of it
|
|
244
|
+
// fits, or sessionId null when it shows none (the job board,
|
|
245
|
+
// another view, a hidden browser tab). One terminal at a time, so
|
|
246
|
+
// it stops counting towards whichever it showed before. Any window
|
|
247
|
+
// may report; fitPtyToWatchers counts only the owner's.
|
|
248
|
+
const session = msg.sessionId == null ? null : sessions.get(msg.sessionId);
|
|
249
|
+
const fits = Number.isInteger(msg.cols) && Number.isInteger(msg.rows)
|
|
250
|
+
&& msg.cols > 0 && msg.rows > 0 && msg.cols <= MAX_PTY_COLS && msg.rows <= MAX_PTY_ROWS;
|
|
251
|
+
if (msg.sessionId != null && !(session && !session.exited && fits)) break;
|
|
252
|
+
const prev = ws.watching && sessions.get(ws.watching.sessionId);
|
|
253
|
+
ws.watching = session ? { sessionId: session.id, cols: msg.cols, rows: msg.rows } : null;
|
|
254
|
+
if (prev && prev !== session && !prev.exited) fitPtyToWatchers(prev);
|
|
255
|
+
if (session) fitPtyToWatchers(session);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case 'billion-start': {
|
|
259
|
+
// Say why, rather than a Start button that silently does nothing
|
|
260
|
+
// (it can outlive the switch-off: user accounts are read live).
|
|
261
|
+
if (!billionRuns()) {
|
|
262
|
+
ws.send(JSON.stringify({ type: 'spawn-error', command: 'claude', error: 'Billion is off: turned off with BILLION=0, or user accounts are enabled' }));
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
const result = startBillion();
|
|
266
|
+
if (result.error) ws.send(JSON.stringify({ type: 'spawn-error', command: 'claude', error: result.error }));
|
|
267
|
+
// Already running (a second click): everyone has its tab already.
|
|
268
|
+
else if (!result.existing) announceSession(result.session, ws);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'kill': {
|
|
272
|
+
const session = sessions.get(msg.sessionId);
|
|
273
|
+
if (session && !owns(ws, session.ownerId)) { denyControl(ws, session.name, session.ownerId); break; }
|
|
274
|
+
await killSession(msg.sessionId);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case 'add-repo': {
|
|
278
|
+
const result = await addRepo(msg.path, broadcast);
|
|
279
|
+
if (result.error) ws.send(JSON.stringify({ type: 'repo-error', error: result.error }));
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case 'remove-repo': {
|
|
283
|
+
removeRepo(msg.path, broadcast);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
case 'rename-session': {
|
|
287
|
+
const session = sessions.get(msg.sessionId);
|
|
288
|
+
if (!session) break;
|
|
289
|
+
if (!owns(ws, session.ownerId)) { denyControl(ws, session.name, session.ownerId); break; }
|
|
290
|
+
// send_message finds Billion by name, so the name is fixed.
|
|
291
|
+
if (session.isBillion) {
|
|
292
|
+
ws.send(JSON.stringify({ type: 'notification', level: 'error', message: `${session.name}'s name can't be changed` }));
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
const name = typeof msg.name === 'string' ? msg.name.trim().slice(0, 40) : '';
|
|
296
|
+
if (!/[a-zA-Z0-9]/.test(name) || name === session.name) break;
|
|
297
|
+
// The pool holds every live label, every orphan's label, and every
|
|
298
|
+
// worktree directory's codename, so one lookup covers all three. The
|
|
299
|
+
// one name this session may reclaim is its own directory's codename.
|
|
300
|
+
const dirName = session.worktreePath ? basename(session.worktreePath) : null;
|
|
301
|
+
if (codenamePool.has(name) && name !== dirName) {
|
|
302
|
+
ws.send(JSON.stringify({ type: 'notification', level: 'error', message: `An agent named ${name} already exists` }));
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
const oldName = session.name;
|
|
306
|
+
session.name = name;
|
|
307
|
+
// Only the label moves. The worktree directory keeps its codename:
|
|
308
|
+
// moving it under a live PTY would strand the agent's cwd. That codename
|
|
309
|
+
// stays reserved until the directory is gone (killSession, delete-orphan),
|
|
310
|
+
// or the next spawn in this repo would try to create the same path.
|
|
311
|
+
if (oldName !== dirName) codenamePool.recycle(oldName);
|
|
312
|
+
codenamePool.addUsed(name);
|
|
313
|
+
const rec = config.activeSessions.find(s => s.worktreePath && s.worktreePath === session.worktreePath);
|
|
314
|
+
if (rec) rec.name = name;
|
|
315
|
+
for (const job of allJobs()) {
|
|
316
|
+
if (job.agentSessionId === session.id) job.agentName = name;
|
|
317
|
+
}
|
|
318
|
+
saveConfig(broadcast);
|
|
319
|
+
broadcast({ type: 'session-renamed', sessionId: session.id, name });
|
|
320
|
+
broadcastJobs(broadcast);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case 'refresh-tree': {
|
|
324
|
+
const session = sessions.get(msg.sessionId);
|
|
325
|
+
if (session && !owns(ws, session.ownerId)) { denyControl(ws, session.name, session.ownerId); break; }
|
|
326
|
+
if (session) { session.lastTreeHash = null; await scanFileTree(session, broadcast); }
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case 'get-diff': {
|
|
330
|
+
const session = sessions.get(msg.sessionId);
|
|
331
|
+
if (session) {
|
|
332
|
+
const diff = await getDiff(session, msg.filePath, msg.status);
|
|
333
|
+
ws.send(JSON.stringify({ type: 'file-diff', sessionId: session.id, filePath: msg.filePath, diff }));
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case 'get-full-tree': {
|
|
338
|
+
const session = sessions.get(msg.sessionId);
|
|
339
|
+
if (session && session.worktreePath) {
|
|
340
|
+
try {
|
|
341
|
+
const [lsOutput, statusOutput] = await Promise.all([
|
|
342
|
+
gitExec(['-C', session.worktreePath, 'ls-files'], { timeout: GIT_USER_TIMEOUT }),
|
|
343
|
+
gitExec(['-C', session.worktreePath, 'status', '--porcelain=v1']),
|
|
344
|
+
]);
|
|
345
|
+
const changedFiles = parseGitStatus(statusOutput);
|
|
346
|
+
const changedMap = new Map(changedFiles.map(f => [f.path, f.status]));
|
|
347
|
+
const allFiles = [];
|
|
348
|
+
for (const line of lsOutput.split('\n')) {
|
|
349
|
+
const p = line.trim();
|
|
350
|
+
if (!p) continue;
|
|
351
|
+
allFiles.push({ path: p, status: changedMap.get(p) || null });
|
|
352
|
+
}
|
|
353
|
+
for (const f of changedFiles) { if (f.status === '?') allFiles.push(f); }
|
|
354
|
+
const tree = buildFileTree(allFiles, basename(session.repoPath));
|
|
355
|
+
ws.send(JSON.stringify({ type: 'full-tree', sessionId: session.id, tree }));
|
|
356
|
+
} catch (err) { console.error(`Full tree fetch failed for ${session.id}:`, err.message); }
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
case 're-adopt-orphan': {
|
|
361
|
+
const orphan = orphans.get(msg.orphanId);
|
|
362
|
+
if (!orphan) { ws.send(JSON.stringify({ type: 'spawn-error', error: 'Orphan not found' })); break; }
|
|
363
|
+
if (!owns(ws, orphan.ownerId)) { denyControl(ws, orphan.name, orphan.ownerId); break; }
|
|
364
|
+
if (adoptingOrphans.has(msg.orphanId)) { ws.send(JSON.stringify({ type: 'spawn-error', error: 'Orphan is already being re-adopted' })); break; }
|
|
365
|
+
adoptingOrphans.add(msg.orphanId);
|
|
366
|
+
if (!existsSync(join(orphan.worktreePath, '.git'))) {
|
|
367
|
+
let recreated = false;
|
|
368
|
+
if (orphan.repoPath && orphan.branchName && existsSync(orphan.repoPath)) {
|
|
369
|
+
try {
|
|
370
|
+
try { rmSync(orphan.worktreePath, { recursive: true }); } catch {}
|
|
371
|
+
await gitExec(['-C', orphan.repoPath, 'worktree', 'prune']);
|
|
372
|
+
await gitExec(['-C', orphan.repoPath, 'worktree', 'add', orphan.worktreePath, orphan.branchName]);
|
|
373
|
+
recreated = true;
|
|
374
|
+
} catch (err) { console.error(`Failed to re-create worktree for ${orphan.name}:`, err.message || err.stderr); }
|
|
375
|
+
}
|
|
376
|
+
if (!recreated) {
|
|
377
|
+
ws.send(JSON.stringify({ type: 'spawn-error', error: 'Worktree directory no longer exists' }));
|
|
378
|
+
adoptingOrphans.delete(msg.orphanId);
|
|
379
|
+
// Dropping the orphan record has to hand its codename back, or the
|
|
380
|
+
// name is burned for the life of the process — nothing else releases
|
|
381
|
+
// it. The branch needs no bookkeeping: git is asked directly at spawn
|
|
382
|
+
// time, so whether this branch still exists takes care of itself.
|
|
383
|
+
codenamePool.recycle(orphan.name);
|
|
384
|
+
if (orphan.worktreePath) codenamePool.recycle(basename(orphan.worktreePath)); // differs after a rename
|
|
385
|
+
orphans.delete(msg.orphanId);
|
|
386
|
+
syncOrphansToConfig(broadcast);
|
|
387
|
+
broadcastOrphansList();
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Per CLI: a Codex agent revived with `claude --continue` has no
|
|
392
|
+
// conversation to continue and dies at once. The new session keeps
|
|
393
|
+
// only what the orphan RECORD said (possibly nothing): a CLI picked
|
|
394
|
+
// by a card, a transcript or the default is a guess, and writing it
|
|
395
|
+
// down would make a wrong one permanent.
|
|
396
|
+
const { command, mode, flags } = orphanResumePlan(orphan);
|
|
397
|
+
// Matched on the branch, as relinkSessionToJob does below.
|
|
398
|
+
const card = allJobs().find(j => j.branchName && j.branchName === orphan.branchName && j.repoPath === orphan.repoPath);
|
|
399
|
+
const result = createSessionFromConfig({
|
|
400
|
+
sessionId: nextSessionId(),
|
|
401
|
+
name: orphan.name,
|
|
402
|
+
color: orphan.color,
|
|
403
|
+
command,
|
|
404
|
+
repoPath: orphan.repoPath,
|
|
405
|
+
worktreePath: orphan.worktreePath,
|
|
406
|
+
branchName: orphan.branchName,
|
|
407
|
+
repoSlug: orphan.repoSlug,
|
|
408
|
+
cocktail: (orphan.branchName || '').split('/').pop(),
|
|
409
|
+
isTUI: true,
|
|
410
|
+
ownerId: orphan.ownerId || null,
|
|
411
|
+
agent: isValidJobAgent(orphan.agent) ? orphan.agent : null,
|
|
412
|
+
// Under a card's or the board's mode the session records no
|
|
413
|
+
// flags of its own: they decide again next time. Under its own
|
|
414
|
+
// recorded flags, it keeps them.
|
|
415
|
+
permissionFlags: mode ? [] : flags,
|
|
416
|
+
origin: orphan.origin === 'board' ? 'board' : 'user',
|
|
417
|
+
approvalsToBillion: card?.postedByBillion === true,
|
|
418
|
+
}, broadcast);
|
|
419
|
+
if (result.error) {
|
|
420
|
+
adoptingOrphans.delete(msg.orphanId);
|
|
421
|
+
ws.send(JSON.stringify({ type: 'spawn-error', command, error: result.error }));
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
const session = result.session;
|
|
425
|
+
sessions.set(session.id, session);
|
|
426
|
+
saveActiveSession(session, broadcast);
|
|
427
|
+
startTreeScanLoop(session, broadcast);
|
|
428
|
+
// If this orphan was a job's agent, put them back together — matched
|
|
429
|
+
// on the branch, the only identifier that survives a restart.
|
|
430
|
+
const relinked = relinkSessionToJob(session, broadcast);
|
|
431
|
+
if (relinked) {
|
|
432
|
+
broadcast({
|
|
433
|
+
type: 'notification', level: 'info',
|
|
434
|
+
message: `${session.name} reconnected to job "${relinked.title}"`,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
adoptingOrphans.delete(msg.orphanId);
|
|
438
|
+
orphans.delete(msg.orphanId);
|
|
439
|
+
syncOrphansToConfig(broadcast);
|
|
440
|
+
announceSession(session, ws);
|
|
441
|
+
broadcastOrphansList();
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'delete-orphan': {
|
|
445
|
+
const orphan = orphans.get(msg.orphanId);
|
|
446
|
+
if (!orphan) break;
|
|
447
|
+
if (!owns(ws, orphan.ownerId)) { denyControl(ws, orphan.name, orphan.ownerId); break; }
|
|
448
|
+
let worktreeRemoved = true;
|
|
449
|
+
if (existsSync(orphan.worktreePath)) {
|
|
450
|
+
try { await gitExec(['-C', orphan.repoPath, 'worktree', 'remove', '--force', orphan.worktreePath]); } catch (err) {
|
|
451
|
+
console.error('Failed to remove orphan worktree:', err.message);
|
|
452
|
+
worktreeRemoved = false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!worktreeRemoved) {
|
|
456
|
+
broadcast({ type: 'notification', level: 'error', message: `Failed to delete orphan ${orphan.name} — worktree removal failed` });
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
await deleteBranch(orphan.repoPath, orphan.branchName);
|
|
460
|
+
codenamePool.recycle(orphan.name);
|
|
461
|
+
if (orphan.worktreePath) codenamePool.recycle(basename(orphan.worktreePath)); // differs after a rename
|
|
462
|
+
orphans.delete(msg.orphanId);
|
|
463
|
+
syncOrphansToConfig(broadcast);
|
|
464
|
+
broadcastOrphansList();
|
|
465
|
+
broadcast({ type: 'notification', level: 'info', message: `Deleted orphan ${orphan.name} — worktree and branch removed` });
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
case 'upload-file': {
|
|
469
|
+
const session = sessions.get(msg.sessionId);
|
|
470
|
+
if (session && !owns(ws, session.ownerId)) { denyControl(ws, session.name, session.ownerId); break; }
|
|
471
|
+
if (!session || !session.worktreePath) { ws.send(JSON.stringify({ type: 'notification', level: 'error', message: 'No worktree for file upload' })); break; }
|
|
472
|
+
if (!msg.filename || !msg.data) { ws.send(JSON.stringify({ type: 'notification', level: 'error', message: 'Invalid upload data' })); break; }
|
|
473
|
+
const buf = Buffer.from(msg.data, 'base64');
|
|
474
|
+
if (buf.length > 10 * 1024 * 1024) { ws.send(JSON.stringify({ type: 'notification', level: 'error', message: 'File too large (max 10MB)' })); break; }
|
|
475
|
+
const uploadsDir = join(session.worktreePath, '.uploads');
|
|
476
|
+
mkdirSync(uploadsDir, { recursive: true });
|
|
477
|
+
let finalName = safeFilename(msg.filename);
|
|
478
|
+
if (existsSync(join(uploadsDir, finalName))) {
|
|
479
|
+
const ext = finalName.includes('.') ? '.' + finalName.split('.').pop() : '';
|
|
480
|
+
const base = finalName.includes('.') ? finalName.slice(0, finalName.lastIndexOf('.')) : finalName;
|
|
481
|
+
let counter = 1;
|
|
482
|
+
while (existsSync(join(uploadsDir, `${base}-${counter}${ext}`))) counter++;
|
|
483
|
+
finalName = `${base}-${counter}${ext}`;
|
|
484
|
+
}
|
|
485
|
+
writeFileSync(join(uploadsDir, finalName), buf);
|
|
486
|
+
const relativePath = `.uploads/${finalName}`;
|
|
487
|
+
if (!session.exited) {
|
|
488
|
+
// Typed into the composer like a keystroke: holds agent messages back.
|
|
489
|
+
session.lastUserInputAt = Date.now();
|
|
490
|
+
session.pty.write(relativePath);
|
|
491
|
+
}
|
|
492
|
+
ws.send(JSON.stringify({ type: 'upload-complete', sessionId: msg.sessionId, path: relativePath, filename: finalName }));
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// --- Job board ---
|
|
497
|
+
// Deliberately not ownership-gated, matching add-repo/remove-repo: the
|
|
498
|
+
// board is shared workspace state, not a per-user resource. postedBy is
|
|
499
|
+
// recorded for attribution, not access control. (Auth here is identity,
|
|
500
|
+
// not a sandbox — see the note at the top of server/auth.js.)
|
|
501
|
+
case 'job-create': {
|
|
502
|
+
const result = addJob({
|
|
503
|
+
title: msg.title, detail: msg.detail, repoPath: msg.repoPath,
|
|
504
|
+
type: msg.jobType, schedule: msg.schedule, attachments: msg.attachments,
|
|
505
|
+
// Empty means "inherit the board setting" — a real unset, so the
|
|
506
|
+
// card follows the board if that changes before it is dispatched.
|
|
507
|
+
permissionMode: msg.permissionMode,
|
|
508
|
+
agent: msg.agent,
|
|
509
|
+
requiresPr: msg.requiresPr,
|
|
510
|
+
postedBy: ws.user ? ws.user.id : null,
|
|
511
|
+
postedByName: ws.user ? ws.user.displayName : null,
|
|
512
|
+
}, broadcast);
|
|
513
|
+
if (result.error) ws.send(JSON.stringify({ type: 'notification', level: 'error', message: result.error }));
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case 'job-update': {
|
|
517
|
+
const result = updateJob(msg.jobId, {
|
|
518
|
+
title: msg.title, detail: msg.detail, repoPath: msg.repoPath,
|
|
519
|
+
type: msg.jobType, schedule: msg.schedule, attachments: msg.attachments,
|
|
520
|
+
permissionMode: msg.permissionMode, agent: msg.agent, requiresPr: msg.requiresPr,
|
|
521
|
+
}, broadcast);
|
|
522
|
+
if (result.error) ws.send(JSON.stringify({ type: 'notification', level: 'error', message: result.error }));
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case 'job-pause': {
|
|
526
|
+
const result = setJobPaused(msg.jobId, msg.paused, broadcast);
|
|
527
|
+
if (result.error) ws.send(JSON.stringify({ type: 'notification', level: 'error', message: result.error }));
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
case 'job-delete': {
|
|
531
|
+
const result = await deleteJob(msg.jobId, broadcast, { killSession });
|
|
532
|
+
if (result.error) ws.send(JSON.stringify({ type: 'notification', level: 'error', message: result.error }));
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
case 'job-move': {
|
|
536
|
+
const result = await moveJob(msg.jobId, msg.state, broadcast, { killSession });
|
|
537
|
+
if (result.error) ws.send(JSON.stringify({ type: 'notification', level: 'error', message: result.error }));
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case 'job-settings': {
|
|
541
|
+
updateSettings({
|
|
542
|
+
running: msg.running, maxPerRepo: msg.maxPerRepo,
|
|
543
|
+
intervalMs: msg.intervalMs, permissionMode: msg.permissionMode,
|
|
544
|
+
}, broadcast);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
// "Run now" — the same tick the timer fires, on demand, so the user
|
|
548
|
+
// never has to wait out the interval to see the board act.
|
|
549
|
+
case 'job-dispatch-now': {
|
|
550
|
+
const { skipped } = await runScan(createSession, broadcast, {
|
|
551
|
+
onSessionCreated: (s) => broadcast(sessionPayload(s)),
|
|
552
|
+
killSession,
|
|
553
|
+
});
|
|
554
|
+
if (skipped) {
|
|
555
|
+
ws.send(JSON.stringify({ type: 'notification', level: 'info', message: 'A scan is already running' }));
|
|
556
|
+
}
|
|
557
|
+
broadcastJobs(broadcast);
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} catch (err) {
|
|
562
|
+
console.error(`WebSocket handler error (msg.type=${msg.type}):`, err);
|
|
563
|
+
if (msg.type === 'spawn') {
|
|
564
|
+
try {
|
|
565
|
+
ws.send(JSON.stringify({
|
|
566
|
+
type: 'spawn-error',
|
|
567
|
+
command: msg.command || 'claude',
|
|
568
|
+
error: `Server error: ${err.message || err}`,
|
|
569
|
+
}));
|
|
570
|
+
} catch {}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
ws.on('close', () => {
|
|
576
|
+
clients.delete(ws);
|
|
577
|
+
// The terminal it was showing can grow back to the windows still open.
|
|
578
|
+
const watched = ws.watching && sessions.get(ws.watching.sessionId);
|
|
579
|
+
if (watched && !watched.exited) fitPtyToWatchers(watched);
|
|
580
|
+
broadcastPresence();
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
}
|