@bill10/agent-007 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
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
+ }
@@ -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();