@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/pty.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// PTY lifecycle — session factory, handlers, state detection
|
|
2
|
+
|
|
3
|
+
import { spawn as spawnPty } from 'node-pty';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { basename } from 'path';
|
|
6
|
+
import { stripAnsiComplete, detectState, createRingBuffer, parseCommand, isRealOutput, trackSyncFrames, ptyEnv } from '../lib/helpers.js';
|
|
7
|
+
// Re-exported so the handler's tests reach the parser through the module they drive.
|
|
8
|
+
export { trackSyncFrames } from '../lib/helpers.js';
|
|
9
|
+
import { resolveExecutable, isUsableCwd } from './command-path.js';
|
|
10
|
+
import { RING_BUFFER_MAX } from './state.js';
|
|
11
|
+
import { mintAgentToken, authEnabled } from './auth.js';
|
|
12
|
+
import { writeMcpConfig, removeMcpConfig, withMcpConfig, takesMcpConfig, withApprovalHook } from './agent-mcp.js';
|
|
13
|
+
import { broadcastJobs } from './jobs.js';
|
|
14
|
+
import { flushMessages, dropMessages } from './messages.js';
|
|
15
|
+
import { sessionAgentFromCommand, permissionFlagsFromCommand } from '../lib/jobs.js';
|
|
16
|
+
import { trustDialogKey } from './billion.js';
|
|
17
|
+
import { dropApprovals } from './approvals.js';
|
|
18
|
+
|
|
19
|
+
// Regex constants for output filtering (shared, not recreated per event)
|
|
20
|
+
|
|
21
|
+
// node-pty's Windows backend creates the real process on a worker callback
|
|
22
|
+
// after spawn() has returned, so a CreateProcessW failure surfaces as an
|
|
23
|
+
// uncaught exception instead of a rejected spawn — and takes the whole office
|
|
24
|
+
// down with it. The checks in createSessionFromConfig cover the two causes we
|
|
25
|
+
// know of (unlaunchable command, missing cwd); this net catches whatever else
|
|
26
|
+
// the console host decides to fail on, so one bad agent costs one tab.
|
|
27
|
+
const ASYNC_SPAWN_FAILURE_RE = /Cannot create process, error code: (\d+)/;
|
|
28
|
+
let asyncSpawnGuardInstalled = false;
|
|
29
|
+
let lastSpawnAttempt = null;
|
|
30
|
+
|
|
31
|
+
function installAsyncSpawnGuard() {
|
|
32
|
+
if (asyncSpawnGuardInstalled) return;
|
|
33
|
+
asyncSpawnGuardInstalled = true;
|
|
34
|
+
process.on('uncaughtException', (err) => {
|
|
35
|
+
const match = ASYNC_SPAWN_FAILURE_RE.exec(err?.message || '');
|
|
36
|
+
if (!match) {
|
|
37
|
+
// Not ours. Reproduce Node's default uncaughtException behaviour rather
|
|
38
|
+
// than silently swallowing an unrelated bug.
|
|
39
|
+
console.error(err);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The failure lands within milliseconds of the spawn that caused it and
|
|
44
|
+
// that session has produced no output, so the last attempt is the culprit.
|
|
45
|
+
const attempt = lastSpawnAttempt;
|
|
46
|
+
lastSpawnAttempt = null;
|
|
47
|
+
const reason = `Failed to start "${attempt?.command || 'command'}" (Windows error ${match[1]})`;
|
|
48
|
+
console.error(`${reason} — session left unstarted.`);
|
|
49
|
+
if (!attempt) return;
|
|
50
|
+
|
|
51
|
+
// Same teardown as the onExit handler below, so a session that died this
|
|
52
|
+
// way reports DISCONNECTED rather than sitting at WORKING in the office,
|
|
53
|
+
// and its board credential does not outlive the process that never ran.
|
|
54
|
+
const { session, broadcast } = attempt;
|
|
55
|
+
session.exited = true;
|
|
56
|
+
clearInterval(session.stateCheckInterval);
|
|
57
|
+
clearTimeout(session.scanTimer);
|
|
58
|
+
removeMcpConfig(session.id);
|
|
59
|
+
dropMessages(session.id);
|
|
60
|
+
updateState(session, broadcast);
|
|
61
|
+
if (broadcast) broadcast({ type: 'session-ended', sessionId: session.id, reason });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Codex's composer placeholder, or its status row: "<model> <effort> · <cwd>",
|
|
66
|
+
// the cwd abbreviated with ~ but keeping its last segment.
|
|
67
|
+
function isCodexPane(text, worktreePath) {
|
|
68
|
+
if (text.includes('Ask Codex to do anything')) return true;
|
|
69
|
+
const tail = worktreePath ? basename(worktreePath) : '';
|
|
70
|
+
return !!tail && text.includes(' · ') && text.includes(tail);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Attach onData + onExit handlers to a PTY process.
|
|
75
|
+
* Shared between createSessionFromConfig and re-adopt-orphan.
|
|
76
|
+
*/
|
|
77
|
+
export function setupPtyHandlers(session, sessionId, broadcast) {
|
|
78
|
+
session.pty.onData((data) => {
|
|
79
|
+
session.ringBuffer.push(data);
|
|
80
|
+
// A pty read is not a line. When a read boundary falls mid-line the line
|
|
81
|
+
// arrives as two fragments, neither of which can match a dialog footer --
|
|
82
|
+
// the session then falls through to a bare TUI WAITING and, since a TUI
|
|
83
|
+
// parked at a question emits nothing further, nothing ever repairs it.
|
|
84
|
+
// So carry the tail past the last newline over to the next chunk.
|
|
85
|
+
//
|
|
86
|
+
// The carry is of RAW bytes, before stripping: a boundary lands mid-escape
|
|
87
|
+
// as readily as mid-word, and half a sequence survives stripAnsiComplete
|
|
88
|
+
// as literal "[7Gto" garbage glued into the line. Escapes never span a
|
|
89
|
+
// newline, so cutting the raw stream there is safe.
|
|
90
|
+
//
|
|
91
|
+
// Bounded because an agent controls this text and a line that never gets a
|
|
92
|
+
// newline would otherwise grow forever. Trimmed from the left, since the
|
|
93
|
+
// next chunk continues on the right.
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
// Frames first: a synchronized repaint is a whole pane, not line text, so
|
|
96
|
+
// only what this read contributed OUTSIDE frames goes on to be reassembled
|
|
97
|
+
// into lines. Codex draws its dialogs and prompt without a newline in
|
|
98
|
+
// sight — left in the line stream, a cancelled picker's text would still
|
|
99
|
+
// be "the last line" for as long as the next 2000 bytes took to arrive,
|
|
100
|
+
// and the last line is checked before the frame.
|
|
101
|
+
// Frames are trusted for Codex alone. Claude Code draws none today but
|
|
102
|
+
// carries the option to, and its repaints are diffs — a frame of its
|
|
103
|
+
// would be one row, not the pane. A Codex frame is the whole pane when it
|
|
104
|
+
// carries the composer or the status row, which names the directory the
|
|
105
|
+
// agent runs in; anything else is a partial repaint, merged onto the pane.
|
|
106
|
+
const { outside, straddle } = session.framesTrusted === false
|
|
107
|
+
? { outside: data, straddle: 0 }
|
|
108
|
+
: trackSyncFrames(session, data, now, { pane: (text) => isCodexPane(text, session.worktreePath) });
|
|
109
|
+
const recentResize = (now - (session.lastResizeAt || 0)) < 2000;
|
|
110
|
+
const carry = straddle ? (session.pendingRaw || '').slice(0, -straddle) : (session.pendingRaw || '');
|
|
111
|
+
const raw = carry + outside;
|
|
112
|
+
const cut = raw.lastIndexOf('\n');
|
|
113
|
+
session.pendingRaw = (cut === -1 ? raw : raw.slice(cut + 1)).slice(-2000);
|
|
114
|
+
const lines = stripAnsiComplete(raw.slice(0, cut + 1)).split('\n').filter(l => l.trim().length > 0);
|
|
115
|
+
answerTrustDialog(session, outside, now);
|
|
116
|
+
const partial = stripAnsiComplete(session.pendingRaw).trim();
|
|
117
|
+
// Freshness is judged on THIS read, never on the carry. `partial` is
|
|
118
|
+
// re-derived from accumulated bytes, so counting it here would re-count
|
|
119
|
+
// text already seen: once an unterminated line sits in the carry, a bare
|
|
120
|
+
// cursor move or a bell would keep bumping lastOutputAt, and detectState
|
|
121
|
+
// reports WORKING for as long as that window stays open. A TUI emitting
|
|
122
|
+
// periodic control sequences could then mask a question dialog for ever —
|
|
123
|
+
// the same failure this carry-over exists to fix, through another door.
|
|
124
|
+
const fresh = stripAnsiComplete(data).trim();
|
|
125
|
+
const hasContent = [...lines, fresh].some(isRealOutput);
|
|
126
|
+
if (hasContent && !recentResize) session.lastOutputAt = now;
|
|
127
|
+
// Capped: these lines are matched against MESSAGE_PATTERNS/PROMPT_PATTERNS
|
|
128
|
+
// on every chunk AND on a 1s per-session interval. An agent controls this
|
|
129
|
+
// text, and no prompt footer is 400 chars, so bound it here. (The last
|
|
130
|
+
// synchronized frame, scanned the same way, has its own larger bound in
|
|
131
|
+
// trackSyncFrames; the patterns with a gap between two literals are
|
|
132
|
+
// themselves bounded now, so neither cap is carrying a quadratic regex.)
|
|
133
|
+
const cap = l => l.trim().slice(0, 400);
|
|
134
|
+
// The partial goes to lastStrippedLine, which detectState scans but which
|
|
135
|
+
// is a single overwritten slot -- keeping it out of the 5-line window, so
|
|
136
|
+
// a frame whose last row never gets a newline cannot latch a fragment
|
|
137
|
+
// there forever. It rejoins the window as a whole line once it completes.
|
|
138
|
+
if (partial) session.lastStrippedLine = cap(partial);
|
|
139
|
+
else if (lines.length > 0) session.lastStrippedLine = cap(lines[lines.length - 1]);
|
|
140
|
+
if (lines.length > 0) {
|
|
141
|
+
session.recentStrippedLines = [...session.recentStrippedLines, ...lines.map(cap)].slice(-5);
|
|
142
|
+
// Whole lines outside any frame: what the last frame is weighed against.
|
|
143
|
+
if (lines.some(isRealOutput)) session.lastLineAt = now;
|
|
144
|
+
}
|
|
145
|
+
broadcast({ type: 'pty-output', sessionId, data: Buffer.from(data).toString('base64') });
|
|
146
|
+
updateState(session, broadcast);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
session.pty.onExit(({ exitCode }) => {
|
|
150
|
+
session.exited = true;
|
|
151
|
+
clearInterval(session.stateCheckInterval);
|
|
152
|
+
clearTimeout(session.scanTimer);
|
|
153
|
+
// The config file holds this agent's board credential. resolveAgentToken
|
|
154
|
+
// already stops honouring the token the moment `exited` is set, so this is
|
|
155
|
+
// about not leaving credentials lying in the filesystem, not about access.
|
|
156
|
+
removeMcpConfig(sessionId);
|
|
157
|
+
dropMessages(sessionId);
|
|
158
|
+
if (session.isBillion) dropApprovals();
|
|
159
|
+
updateState(session, broadcast);
|
|
160
|
+
broadcast({ type: 'session-ended', sessionId, reason: `Process exited with code ${exitCode}` });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
session.stateCheckInterval = setInterval(() => {
|
|
164
|
+
stopBillionUnderAccounts(session);
|
|
165
|
+
updateState(session, broadcast);
|
|
166
|
+
}, 1000);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Billion, and board-dispatched Claude Code workers unless
|
|
170
|
+
// TRUST_BOARD_WORKTREES=0 (server/claude-trust.js pre-seeds their trust; this
|
|
171
|
+
// catches the dialog when that write lost a race). A dialog arrives in several
|
|
172
|
+
// reads, and a late one can still show the old cursor, so reads are collected
|
|
173
|
+
// until the screen has been quiet for a moment and the key is chosen from the
|
|
174
|
+
// settled drawing. Collected from the key onwards only, so an answered drawing
|
|
175
|
+
// is never answered twice. Capped, so a dialog that never changes can't be
|
|
176
|
+
// typed at forever. And only just after spawn: the dialog comes before Claude
|
|
177
|
+
// Code's first prompt, and on every start after the first there is none, so a
|
|
178
|
+
// watcher left armed would read the agent's whole session — and could type into
|
|
179
|
+
// it whenever its own output happened to look like that dialog.
|
|
180
|
+
const TRUST_SETTLE_MS = 400;
|
|
181
|
+
const TRUST_KEY_CAP = 4;
|
|
182
|
+
const TRUST_WINDOW_MS = 60_000;
|
|
183
|
+
const TRUST_SCREEN_CHARS = 8000;
|
|
184
|
+
function answerTrustDialog(session, data, now) {
|
|
185
|
+
if (!session.answersTrust || (session.trustKeys || 0) >= TRUST_KEY_CAP) return;
|
|
186
|
+
if (now - session.createdAt > TRUST_WINDOW_MS) {
|
|
187
|
+
session.trustKeys = TRUST_KEY_CAP;
|
|
188
|
+
session.trustScreen = '';
|
|
189
|
+
clearTimeout(session.trustTimer);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
session.trustScreen = ((session.trustScreen || '') + data).slice(-TRUST_SCREEN_CHARS);
|
|
193
|
+
clearTimeout(session.trustTimer);
|
|
194
|
+
session.trustTimer = setTimeout(() => {
|
|
195
|
+
const key = session.exited ? null : trustDialogKey(stripAnsiComplete(session.trustScreen || ''));
|
|
196
|
+
if (!key) return;
|
|
197
|
+
// Enter answers it for good: stop watching.
|
|
198
|
+
session.trustKeys = key === '\r' ? TRUST_KEY_CAP : (session.trustKeys || 0) + 1;
|
|
199
|
+
session.trustScreen = '';
|
|
200
|
+
try { session.pty.write(key); } catch {}
|
|
201
|
+
}, TRUST_SETTLE_MS);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create a session object and spawn a PTY process.
|
|
206
|
+
* Used by both fresh spawn and orphan re-adopt.
|
|
207
|
+
*/
|
|
208
|
+
export function createSessionFromConfig({ sessionId, name, color, command, repoPath, worktreePath, branchName, repoSlug, cocktail, isTUI, ownerId, spawnedBy, jobId, agent, permissionFlags, origin, cwd: ownCwd, isBillion, approvalsToBillion, autoTrust }, broadcast) {
|
|
209
|
+
const { file, args } = parseCommand(command);
|
|
210
|
+
// ownCwd: a repo-less agent that still has a folder of its own (Billion).
|
|
211
|
+
const cwd = worktreePath || ownCwd || homedir();
|
|
212
|
+
|
|
213
|
+
// Both of these are checked up front because Windows reports them from the
|
|
214
|
+
// console host *after* spawn() returns — see server/command-path.js. An
|
|
215
|
+
// unusable cwd is the usual re-spawn failure: the agent's worktree was
|
|
216
|
+
// deleted while it was parked as an orphan. Checked before the token is
|
|
217
|
+
// minted so a doomed spawn never puts a board credential on disk.
|
|
218
|
+
if (!isUsableCwd(cwd)) {
|
|
219
|
+
return { error: `Working directory no longer exists: ${cwd}` };
|
|
220
|
+
}
|
|
221
|
+
// Falls back to the bare name when nothing matched, leaving node-pty's own
|
|
222
|
+
// lookup (and its catchable "File not found") in charge.
|
|
223
|
+
const resolvedFile = resolveExecutable(file, process.env, process.platform, cwd) || file;
|
|
224
|
+
|
|
225
|
+
// Minted before the spawn so it can go into the MCP config the agent reads at
|
|
226
|
+
// startup, and parked on the session below so resolveAgentToken can find its
|
|
227
|
+
// way back from a request to the agent that made it.
|
|
228
|
+
const agentToken = mintAgentToken();
|
|
229
|
+
// Only for a command that can actually read it. Writing one for every session
|
|
230
|
+
// would put a live board credential on disk for terminals that have no way to
|
|
231
|
+
// use it — a plain `bash` tab does not need one.
|
|
232
|
+
const mcpConfigPath = takesMcpConfig(file) ? writeMcpConfig(sessionId, agentToken) : null;
|
|
233
|
+
const mcpArgs = withMcpConfig(file, args, mcpConfigPath);
|
|
234
|
+
// A worker on one of Billion's cards asks Billion before it asks a person
|
|
235
|
+
// (server/approvals.js) — where the CLI can be hooked, which today is
|
|
236
|
+
// Claude Code only. Recorded as whether the hook actually went in.
|
|
237
|
+
const spawnArgs = approvalsToBillion ? withApprovalHook(file, mcpArgs, mcpConfigPath) : mcpArgs;
|
|
238
|
+
const hooked = spawnArgs !== mcpArgs;
|
|
239
|
+
|
|
240
|
+
installAsyncSpawnGuard();
|
|
241
|
+
let ptyProcess;
|
|
242
|
+
try {
|
|
243
|
+
ptyProcess = spawnPty(resolvedFile, spawnArgs, {
|
|
244
|
+
name: 'xterm-256color',
|
|
245
|
+
cols: 120,
|
|
246
|
+
rows: 30,
|
|
247
|
+
cwd,
|
|
248
|
+
env: ptyEnv(process.env),
|
|
249
|
+
});
|
|
250
|
+
} catch (err) {
|
|
251
|
+
removeMcpConfig(sessionId); // nothing will ever read it now
|
|
252
|
+
return { error: `Failed to start "${command}". Is the command installed?` };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const session = {
|
|
256
|
+
id: sessionId,
|
|
257
|
+
name,
|
|
258
|
+
color,
|
|
259
|
+
command,
|
|
260
|
+
createdAt: Date.now(),
|
|
261
|
+
pty: ptyProcess,
|
|
262
|
+
ringBuffer: createRingBuffer(RING_BUFFER_MAX),
|
|
263
|
+
state: 'WORKING',
|
|
264
|
+
lastOutputAt: Date.now(),
|
|
265
|
+
lastResizeAt: 0,
|
|
266
|
+
lastStrippedLine: '',
|
|
267
|
+
recentStrippedLines: [],
|
|
268
|
+
framesTrusted: sessionAgentFromCommand(command) === 'codex', // see the onData handler
|
|
269
|
+
lastFrame: '', // text of the last synchronized-output repaint, if the TUI draws them
|
|
270
|
+
lastFrameAt: 0, // when it closed — the frame speaks for the screen only while no whole line was printed outside one since
|
|
271
|
+
lastLineAt: 0, // when a whole real line last arrived outside any frame
|
|
272
|
+
frameOpenedAt: 0, // a frame open longer than a second is abandoned
|
|
273
|
+
frameOpen: null, // a frame carried across pty reads
|
|
274
|
+
frameTail: '', // the last few bytes before one, for a marker that straddles two reads
|
|
275
|
+
pendingRaw: '', // tail of the last pty chunk, past its final newline
|
|
276
|
+
isTUI: isTUI ?? /^(claude|aider|codex|gemini)\b/.test(command),
|
|
277
|
+
// Which CLI this is, as far as anyone KNOWS — 'claude', 'codex' or null.
|
|
278
|
+
// A fresh spawn reads it off the command. A re-adopted orphan passes its
|
|
279
|
+
// own note instead, which may be null: its resume command was chosen by
|
|
280
|
+
// a card, a transcript or the default, and a guess must not be written
|
|
281
|
+
// down as fact, or a wrong one could never be corrected — the record
|
|
282
|
+
// outranks every other witness the next time round.
|
|
283
|
+
agent: agent === undefined ? sessionAgentFromCommand(command) : agent,
|
|
284
|
+
// The permission flags it was spawned with, so a re-spawn with no job
|
|
285
|
+
// card to ask can run under the same ones. Only a session that OWNS its
|
|
286
|
+
// flags records them: one a person started by hand. A board dispatch
|
|
287
|
+
// runs under its card's mode, which the board re-resolves at every
|
|
288
|
+
// re-spawn against its current setting, so recording the dispatch-time
|
|
289
|
+
// flags would freeze a bypass past the card's retirement and past any
|
|
290
|
+
// tightening of the board since. A re-adopt passes its own answer in:
|
|
291
|
+
// the recorded flags it resumed under, or none when a card's mode did.
|
|
292
|
+
permissionFlags: permissionFlags !== undefined ? permissionFlags
|
|
293
|
+
: ((origin || spawnedBy) === 'board' ? [] : permissionFlagsFromCommand(command)),
|
|
294
|
+
// Where the session's lineage began, 'board' or 'user' — unlike spawnedBy
|
|
295
|
+
// (which says how THIS tab was opened, and is 'user' for a re-adopt) it
|
|
296
|
+
// survives re-adopts on the records, so a board agent whose card is gone
|
|
297
|
+
// can still be resumed under the board's mode rather than the CLI's.
|
|
298
|
+
origin: origin === 'board' || (origin === undefined && spawnedBy === 'board') ? 'board' : 'user',
|
|
299
|
+
ownerId: ownerId || null, // user who spawned this session (phase 2); null = unowned
|
|
300
|
+
agentToken, // bearer for this agent's own board calls; memory + one 0600 file
|
|
301
|
+
// Provenance. 'board' sessions are opened by the job dispatcher: the client
|
|
302
|
+
// uses this to add the tab WITHOUT stealing focus, since an unattended
|
|
303
|
+
// dispatcher would otherwise yank the user's cursor away every few minutes.
|
|
304
|
+
spawnedBy: spawnedBy || 'user',
|
|
305
|
+
jobId: jobId || null, // job this session was dispatched for, if any
|
|
306
|
+
isBillion: !!isBillion, // the one agent you talk to (server/billion.js)
|
|
307
|
+
answersTrust: !!(isBillion || autoTrust), // see answerTrustDialog
|
|
308
|
+
approvalsToBillion: hooked, // its permission dialogs go to Billion first
|
|
309
|
+
exited: false,
|
|
310
|
+
stateCheckInterval: null,
|
|
311
|
+
repoPath,
|
|
312
|
+
worktreePath,
|
|
313
|
+
branchName,
|
|
314
|
+
repoSlug,
|
|
315
|
+
cocktail,
|
|
316
|
+
fileTree: [],
|
|
317
|
+
changedCount: 0,
|
|
318
|
+
additions: 0,
|
|
319
|
+
removals: 0,
|
|
320
|
+
lastTreeHash: null,
|
|
321
|
+
scanTimer: null,
|
|
322
|
+
scannedOnce: false, // first scan cycle always runs (see startTreeScanLoop)
|
|
323
|
+
lastScanOutputAt: null, // value of lastOutputAt at the last git scan (idle gate)
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
lastSpawnAttempt = { session, command, broadcast };
|
|
327
|
+
setupPtyHandlers(session, sessionId, broadcast);
|
|
328
|
+
return { session };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// User accounts are read live, so they can appear while Billion runs. It
|
|
332
|
+
// belongs to no one, so every signed-in user could then drive an agent that
|
|
333
|
+
// never asks before acting: it stops, and Start stays refused (billionRuns).
|
|
334
|
+
// On the one-second tick, not on every chunk of output: it stats a file.
|
|
335
|
+
export function stopBillionUnderAccounts(session) {
|
|
336
|
+
if (session.isBillion && !session.exited && authEnabled()) {
|
|
337
|
+
try { session.pty.kill(); } catch {}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function updateState(session, broadcast) {
|
|
342
|
+
const prevState = session.state;
|
|
343
|
+
const newState = detectState(session);
|
|
344
|
+
if (newState !== prevState) {
|
|
345
|
+
session.state = newState;
|
|
346
|
+
session.stateChangedAt = Date.now(); // messages.js: one message per stop
|
|
347
|
+
if (broadcast) broadcast({ type: 'state-change', sessionId: session.id, state: newState });
|
|
348
|
+
// A job card's "needs you" badge is derived from its agent's state, so the
|
|
349
|
+
// board has to be re-sent when that state moves. The browser recomputes the
|
|
350
|
+
// badge locally from state-change too, but the jobs-list `status` field
|
|
351
|
+
// would otherwise stay frozen at whatever it was when the job was
|
|
352
|
+
// dispatched — stale for any other consumer, and for a client that
|
|
353
|
+
// connects mid-flight. Only board sessions trigger this, and only on an
|
|
354
|
+
// actual transition, so it is a handful of messages per job.
|
|
355
|
+
if (session.jobId && broadcast) broadcastJobs(broadcast);
|
|
356
|
+
}
|
|
357
|
+
// Every check, not only on arriving at WAITING: a message held back because
|
|
358
|
+
// someone was typing has no later transition to wait for.
|
|
359
|
+
flushMessages(session);
|
|
360
|
+
}
|
package/server/state.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Shared mutable state — single owner for all Maps, Sets, and singletons.
|
|
2
|
+
// All other modules import from here. No circular deps.
|
|
3
|
+
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import {
|
|
7
|
+
createCodenamePool, createCocktailPool, createColorCycler,
|
|
8
|
+
} from '../lib/helpers.js';
|
|
9
|
+
|
|
10
|
+
// --- Constants ---
|
|
11
|
+
export const PORT = process.env.PORT || 7007;
|
|
12
|
+
|
|
13
|
+
// --- Network binding & origin allowlist (Phase 0: remote access) ---
|
|
14
|
+
// HOST controls the bind interface. Default 127.0.0.1 keeps the server
|
|
15
|
+
// localhost-only (single-player). Set HOST=0.0.0.0 (or a specific tailscale IP)
|
|
16
|
+
// to reach it from other machines — only do this behind Tailscale or another
|
|
17
|
+
// trusted network, since the server spawns real shells.
|
|
18
|
+
export const HOST = process.env.HOST || '127.0.0.1';
|
|
19
|
+
|
|
20
|
+
// Loopback binds are localhost-only (never warn about remote exposure); wildcard
|
|
21
|
+
// binds accept remote connections. Shared so server.js and the origin logic agree
|
|
22
|
+
// on what "local" is. (These are HOST bind-string forms — `::1`, not `[::1]`.)
|
|
23
|
+
export const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
|
|
24
|
+
export const WILDCARD_BIND_HOSTS = ['0.0.0.0', '::'];
|
|
25
|
+
|
|
26
|
+
// Origins are matched by hostname. Loopback hosts are always allowed so the local
|
|
27
|
+
// browser keeps working (note: the IPv6 loopback Origin header serializes as the
|
|
28
|
+
// bracketed `[::1]`). Add remote hostnames (e.g. your tailnet name) via
|
|
29
|
+
// ALLOWED_ORIGINS, comma-separated. Entries may be bare hostnames
|
|
30
|
+
// (`mac-mini.tailnet.ts.net`), host:port (`mac-mini:7007`), or full origins
|
|
31
|
+
// (`https://mac-mini:7007`) — only the hostname is used. A single `*` disables
|
|
32
|
+
// the check entirely (allows ANY origin): on the default localhost bind that lets
|
|
33
|
+
// any website you visit drive the server through your browser — avoid it.
|
|
34
|
+
const DEFAULT_ORIGIN_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
|
|
35
|
+
const rawOrigins = (process.env.ALLOWED_ORIGINS || '')
|
|
36
|
+
.split(',').map(s => s.trim()).filter(Boolean);
|
|
37
|
+
const ALLOW_ALL_ORIGINS = rawOrigins.includes('*');
|
|
38
|
+
const originHostsFromEnv = rawOrigins
|
|
39
|
+
.filter(o => o !== '*')
|
|
40
|
+
.map(entry => {
|
|
41
|
+
// Add a scheme when the entry lacks one, else new URL() parses a bare
|
|
42
|
+
// "host:port" as scheme+path and yields an empty hostname (silent lockout).
|
|
43
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(entry) ? entry : `http://${entry}`;
|
|
44
|
+
try { return new URL(withScheme).hostname; } catch { return null; }
|
|
45
|
+
})
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
const ALLOWED_ORIGIN_HOSTS = new Set([...DEFAULT_ORIGIN_HOSTS, ...originHostsFromEnv]);
|
|
48
|
+
|
|
49
|
+
// Shared origin gate for both HTTP (server/http.js) and WS (server/ws.js).
|
|
50
|
+
// Requests with no Origin header (same-origin browser requests, curl, native WS
|
|
51
|
+
// clients) are allowed through — cross-origin browsers are the threat model.
|
|
52
|
+
// NOTE: this only blocks cross-origin *browser* requests; it is NOT access
|
|
53
|
+
// control. Non-browser clients send no Origin and pass. When HOST is remote, the
|
|
54
|
+
// network boundary (Tailscale / trusted LAN) is what actually gates access.
|
|
55
|
+
export function isAllowedOrigin(origin) {
|
|
56
|
+
if (!origin) return true;
|
|
57
|
+
if (ALLOW_ALL_ORIGINS) return true;
|
|
58
|
+
try {
|
|
59
|
+
return ALLOWED_ORIGIN_HOSTS.has(new URL(origin).hostname);
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const RING_BUFFER_MAX = 5000;
|
|
66
|
+
export const GIT_AUTO_TIMEOUT = 5000;
|
|
67
|
+
export const GIT_USER_TIMEOUT = 30000;
|
|
68
|
+
// Overridable so tests never write to the developer's live ~/.agent-007/config.json
|
|
69
|
+
// (mirrors AGENT007_WORKTREE_DIR and AGENT007_USERS_PATH, both set by test/setup.js).
|
|
70
|
+
// saveConfig() rewrites this file wholesale from in-memory state, so a test that
|
|
71
|
+
// touches config would otherwise silently destroy the real repo/orphan list.
|
|
72
|
+
export const CONFIG_DIR = process.env.AGENT007_CONFIG_DIR || join(homedir(), '.agent-007');
|
|
73
|
+
// Overridable so tests never create worktrees in the developer's live
|
|
74
|
+
// ~/.agent-007/worktrees (mirrors AGENT007_USERS_PATH, which test/setup.js uses).
|
|
75
|
+
export const WORKTREE_DIR = process.env.AGENT007_WORKTREE_DIR || join(CONFIG_DIR, 'worktrees');
|
|
76
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
77
|
+
|
|
78
|
+
// --- Mutable state ---
|
|
79
|
+
export const sessions = new Map();
|
|
80
|
+
export let sessionCounter = 0;
|
|
81
|
+
|
|
82
|
+
// Per-process, so an id from one server generation can never match a session in
|
|
83
|
+
// the next. The counter alone restarts at zero, and job records outlive the
|
|
84
|
+
// process: a job holding `session-5` from before a restart would silently
|
|
85
|
+
// resolve to whatever `session-5` happens to be now — an unrelated agent on a
|
|
86
|
+
// different branch. Observed: a review card reporting its agent alive when that
|
|
87
|
+
// id belonged to someone else's job entirely.
|
|
88
|
+
// pid as well as the clock: two processes can start in the same millisecond
|
|
89
|
+
// (parallel test runners, a restart racing a supervisor).
|
|
90
|
+
const SESSION_ID_PREFIX = `session-${Date.now().toString(36)}${process.pid.toString(36)}`;
|
|
91
|
+
export function nextSessionId() { return `${SESSION_ID_PREFIX}-${++sessionCounter}`; }
|
|
92
|
+
|
|
93
|
+
export const orphans = new Map();
|
|
94
|
+
export const adoptingOrphans = new Set();
|
|
95
|
+
|
|
96
|
+
export let config = { version: 1, repos: [], orphans: [], activeSessions: [] };
|
|
97
|
+
export function setConfig(c) { config = c; }
|
|
98
|
+
|
|
99
|
+
export const knownConflictKeys = new Set();
|
|
100
|
+
|
|
101
|
+
// --- Pools ---
|
|
102
|
+
export const codenamePool = createCodenamePool();
|
|
103
|
+
export const cocktailPool = createCocktailPool();
|
|
104
|
+
export const colorCycler = createColorCycler();
|