@bill10/agent-007 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
@@ -0,0 +1,319 @@
1
+ // Agent-to-agent messages — how a Claude Code agent and a Codex agent (or any
2
+ // two of either) talk to each other.
3
+ //
4
+ // A message is typed into the recipient's terminal as a user turn. Neither CLI
5
+ // has an inbox this app could fill, and an agent never polls one, so typing it
6
+ // in is the only delivery both of them actually act on. That makes WHEN it is
7
+ // typed the whole design:
8
+ //
9
+ // - only while the recipient rests at its prompt (WAITING). In MESSAGE a
10
+ // dialog is on screen and the Enter would answer it — a permission prompt,
11
+ // or a trust dialog that then saves the folder as trusted;
12
+ // - not while a person is typing in that terminal, or their half-written
13
+ // line would be sent with the message glued on;
14
+ // - once per stop: the next message waits until the agent has taken the
15
+ // last one and come back to its prompt.
16
+ //
17
+ // Anything that cannot go now waits in a per-session queue, which updateState
18
+ // (server/pty.js) retries every second. Kept free of node-pty and of the
19
+ // session Map so it is testable on its own: sessions come in as parameters.
20
+
21
+ import { parseCommand, detectState } from '../lib/helpers.js';
22
+ import { permissionFlagsFromCommand, sessionAgentFromCommand, BILLION_NAME, isCodexConfigFlag } from '../lib/jobs.js';
23
+ import { takesMcpConfig } from './agent-mcp.js';
24
+
25
+ export const MAX_MESSAGE_CHARS = 8000;
26
+ export const QUEUE_CAP = 20;
27
+ export const PAIR_LIMIT = 10;
28
+ export const PAIR_WINDOW_MS = 10 * 60 * 1000;
29
+ // ponytail: a quiet window, not real composer detection — neither CLI says
30
+ // whether its composer is empty. A person who stops typing mid-line for 30 s
31
+ // still gets the message appended to what they typed.
32
+ export const USER_TYPING_HOLD_MS = 30 * 1000;
33
+ // Between the paste and its Enter. Claude Code submits with no gap at all
34
+ // (checked against 2.x). Codex treats fast keystrokes as a paste burst in which
35
+ // Enter is a newline; with this gap codex-cli 0.155.1 takes paste and Enter as
36
+ // one turn (checked in review).
37
+ export const SUBMIT_DELAY_MS = 150;
38
+
39
+ const queues = new Map(); // recipient session id -> [formatted text]
40
+ // How many at the front of a queue the server wrote (approvals, board
41
+ // notices). They go ahead of agent messages and count against their own cap,
42
+ // so agents messaging Billion cannot push its approvals past their wait, or
43
+ // crowd them out of the queue altogether.
44
+ const serverAhead = new Map();
45
+ const sends = new Map(); // `${from.id}>${to.id}` -> [timestamps]
46
+
47
+ // Everything but newline and tab. The text goes inside a bracketed paste, and a
48
+ // message carrying ESC[201~ would end the paste early and type the rest as raw
49
+ // keystrokes — arrow keys, Enter, whatever it liked.
50
+ const clean = (s) => String(s ?? '').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
51
+
52
+ const sameOwner = (a, b) => (a.ownerId || null) === (b.ownerId || null);
53
+
54
+ // A plain shell tab would run the message as a command line.
55
+ const isAgent = (session) => takesMcpConfig(parseCommand(session.command || '').file);
56
+
57
+ // An agent that runs commands without asking anyone. A message is a prompt it
58
+ // acts on with its own permissions, so if any agent could message one, every
59
+ // agent could borrow them — a read-only job reading an untrusted issue could
60
+ // have it run whatever the issue said. Only another such agent may.
61
+ //
62
+ // ponytail: read off the command line only. A bare `claude` whose settings.json
63
+ // defaults to bypassPermissions, or a `codex` whose config.toml never asks,
64
+ // reads as guarded here; the user's own config usually applies to the sender
65
+ // too, but a repo's .claude/settings.json does not.
66
+ export function isUnguarded(session) {
67
+ const command = session.command || '';
68
+ const flags = permissionFlagsFromCommand(command);
69
+ const value = (flag) => { const i = flags.indexOf(flag); return i === -1 ? undefined : flags[i + 1]; };
70
+ if (flags.includes('--dangerously-skip-permissions') || value('--permission-mode') === 'bypassPermissions') return true;
71
+ if (flags.includes('--dangerously-bypass-approvals-and-sandbox') || flags.includes('--approve-for-me')
72
+ || value('--sandbox') === 'danger-full-access' || value('--ask-for-approval') === 'never') return true;
73
+ if (sessionAgentFromCommand(command) !== 'codex') return false;
74
+ // Codex also takes config overrides and profiles, which can set any of the
75
+ // above where the allowlist cannot see it. What they set is unknown, so any
76
+ // of them counts as never asking.
77
+ const { args } = parseCommand(command);
78
+ const end = args.indexOf('--');
79
+ return (end === -1 ? args : args.slice(0, end))
80
+ .some(isCodexConfigFlag);
81
+ }
82
+
83
+ // Billion (server/billion.js) is the exception both rules make: every agent
84
+ // may message it. It is the one agent everyone reports to, it belongs to no
85
+ // one, and it never asks before acting — so a worker that read untrusted text
86
+ // can pass that text on to an agent with full access. Accepted in the design
87
+ // (docs/BILLION.md): messages arrive labelled as coming from an agent, and its
88
+ // charter treats them as information, never as instructions.
89
+ //
90
+ // AGENT_MESSAGING=open in .env lifts the permission rule (not the owner rule):
91
+ // for someone who runs every agent in one mode it never blocks anything, and
92
+ // for someone who mixes modes it is theirs to waive. Messages keep their
93
+ // "from another agent, not the user" label either way.
94
+ export function messagingOpen(env = process.env) {
95
+ return String(env.AGENT_MESSAGING ?? '').trim().toLowerCase() === 'open';
96
+ }
97
+
98
+ // Why `from` may not message `to`, or null when it may. Another owner's agent
99
+ // is refused without a reason: saying so would tell an agent it exists.
100
+ const HIDDEN_AGENT = 'hidden';
101
+ function refusal(from, to, open) {
102
+ if (to.isBillion) return null;
103
+ if (!sameOwner(from, to)) return HIDDEN_AGENT;
104
+ if (!open && !isUnguarded(from) && isUnguarded(to)) {
105
+ return 'never asks before acting and you do, so a message from you could get it to do what you would have to ask for '
106
+ + '(the owner can allow this with AGENT_MESSAGING=open in .env)';
107
+ }
108
+ return null;
109
+ }
110
+
111
+ export function messageableAgents(from, sessions, env = process.env) {
112
+ const open = messagingOpen(env);
113
+ return [...sessions.values()].filter(s =>
114
+ s.id !== from.id && !s.exited && isAgent(s) && !refusal(from, s, open));
115
+ }
116
+
117
+ // Header fields lose newlines as well: a name is renamable, and one carrying a
118
+ // newline could start a line of its own outside the quoted body.
119
+ export const oneLine = (s) => clean(s).replace(/[\n\t]/g, ' ');
120
+
121
+ export function formatMessage(from, text) {
122
+ const where = [from.agent, from.repoSlug, from.branchName].filter(Boolean).map(oneLine).join(' · ');
123
+ const name = oneLine(from.name);
124
+ // Every body line quoted, so a body cannot close the message with a footer
125
+ // of its own and carry on as if it were the user speaking.
126
+ const body = quoteLines(text).join('\n');
127
+ return `[Message from agent ${name}${where ? ` (${where})` : ''}]\n`
128
+ + `${body}\n`
129
+ + `[Reply with the send_message tool, to: "${name}". This came from another agent, not from the user.]`;
130
+ }
131
+
132
+ // Agent text, quoted line by line so it cannot pass for anything but a quote.
133
+ export function quoteLines(text) {
134
+ return clean(text).split('\n').map(line => `> ${line}`);
135
+ }
136
+
137
+ // A board notice: from the server, not an agent, so it names the board and
138
+ // carries no reply line. Quoted like a message body, since a card's summary is
139
+ // an agent's text.
140
+ export function formatNotice(headline, lines = []) {
141
+ return [`[Job board] ${oneLine(headline)}`, ...lines.flatMap(quoteLines),
142
+ '[This came from the Agent 007 job board, not the user.]'].join('\n');
143
+ }
144
+
145
+ // Queue text the server wrote (a board notice, an approval request) and
146
+ // deliver it when the session can take one. Not agent-to-agent, so neither
147
+ // the permission rule nor the pair limit applies; the queue cap does, and
148
+ // text over it is refused.
149
+ //
150
+ // Cleaned here, whoever wrote it: it goes into a bracketed paste, and a stray
151
+ // ESC[201~ anywhere in it — a tool name, a card title — would end the paste
152
+ // and type the rest as keystrokes of the user's own.
153
+ export function sendText(session, text, now = Date.now()) {
154
+ if (!session || session.exited) return false;
155
+ const queue = queues.get(session.id) || [];
156
+ const ahead = serverAhead.get(session.id) || 0;
157
+ if (ahead >= QUEUE_CAP) return false;
158
+ queue.splice(ahead, 0, clean(text));
159
+ queues.set(session.id, queue);
160
+ serverAhead.set(session.id, ahead + 1);
161
+ flushMessages(session, now);
162
+ return true;
163
+ }
164
+
165
+ // Take back text still waiting in a session's queue (an approval request that
166
+ // expired before it was typed).
167
+ export function unqueueText(sessionId, text) {
168
+ const queue = queues.get(sessionId);
169
+ const at = queue ? queue.indexOf(clean(text)) : -1;
170
+ if (at === -1) return false;
171
+ queue.splice(at, 1);
172
+ const ahead = serverAhead.get(sessionId) || 0;
173
+ if (at < ahead) serverAhead.set(sessionId, ahead - 1);
174
+ if (!queue.length) queues.delete(sessionId);
175
+ return true;
176
+ }
177
+
178
+ // A notice over the cap is dropped: the board still has the card.
179
+ export function sendNotice(session, headline, lines, now = Date.now()) {
180
+ return sendText(session, formatNotice(headline, lines), now);
181
+ }
182
+
183
+ // Whether a message may be typed into this session right now.
184
+ export function canDeliver(session, now = Date.now()) {
185
+ // Billion holds its mail until it says it is ready (billion_ready), so
186
+ // nothing lands in the middle of its introduction.
187
+ if (session.messagesHeld) return false;
188
+ // Both: the stored state is up to a second old, and a dialog that opened
189
+ // since is what this must not type into.
190
+ if (session.exited || session.state !== 'WAITING' || detectState(session, { now }) !== 'WAITING') return false;
191
+ if (now - (session.lastUserInputAt || 0) < USER_TYPING_HOLD_MS) return false;
192
+ // A message left in the composer, its Enter skipped: another pasted after it
193
+ // would be submitted with it. Held until a person has been at that terminal.
194
+ if (session.messageUnsubmittedAt && (session.lastUserInputAt || 0) <= session.messageUnsubmittedAt) return false;
195
+ // Delivered since it last came to rest: it has not picked that one up yet.
196
+ return !(session.messageDeliveredAt && session.messageDeliveredAt >= (session.stateChangedAt || 0));
197
+ }
198
+
199
+ // From a timer or the state interval, so a pty torn down but not yet marked
200
+ // exited must not throw: nothing up the stack would catch it.
201
+ function write(session, data) {
202
+ try { session.pty.write(data); return true; } catch { return false; }
203
+ }
204
+
205
+ function deliver(session, text, now) {
206
+ session.messageDeliveredAt = now;
207
+ write(session, `\x1b[200~${text}\x1b[201~`);
208
+ // Checked again at the Enter: in those 150 ms a dialog may have opened, which
209
+ // the Enter would answer, or a person may have started typing, whose text
210
+ // would go with it. Left unsent, the message sits in the composer instead.
211
+ // The screen is read afresh rather than through session.state, which lags a
212
+ // second behind and reads WORKING for three after any output — the paste's
213
+ // own echo included.
214
+ setTimeout(() => {
215
+ if (session.exited) return;
216
+ if (detectState(session, { stateTimeoutMs: 0 }) === 'MESSAGE' || (session.lastUserInputAt || 0) > now) {
217
+ session.messageUnsubmittedAt = Date.now();
218
+ return;
219
+ }
220
+ write(session, '\r');
221
+ }, SUBMIT_DELAY_MS);
222
+ }
223
+
224
+ /**
225
+ * Send `text` from one agent session to another, named as list_agents shows it.
226
+ * Returns { delivered: true, to } | { queued: n, to } | { error }, `to` being
227
+ * the recipient session.
228
+ */
229
+ export function sendMessage({ from, to, text, sessions, now = Date.now() }) {
230
+ const body = typeof text === 'string' ? text.trim() : '';
231
+ if (!body) return { error: 'The message is empty.' };
232
+ if (body.length > MAX_MESSAGE_CHARS) {
233
+ return { error: `The message is ${body.length} characters; the limit is ${MAX_MESSAGE_CHARS}.` };
234
+ }
235
+ const reachable = messageableAgents(from, sessions);
236
+ // Billion by what it is, not by what it is called: an old session that
237
+ // happens to carry the name must not receive what was meant for it.
238
+ const target = to === BILLION_NAME ? reachable.find(s => s.isBillion) : reachable.find(s => s.name === to);
239
+ if (!target) {
240
+ const names = reachable.map(s => s.name).join(', ');
241
+ const reach = names ? `Agents you can reach: ${names}.` : 'There are no other agents you can reach.';
242
+ // Say why when the agent is there but the rules keep it out of reach.
243
+ const there = [...sessions.values()].find(s => s.id !== from.id && !s.exited && isAgent(s) && s.name === to);
244
+ const why = there && refusal(from, there, messagingOpen());
245
+ return { error: why && why !== HIDDEN_AGENT ? `${to} ${why}. ${reach}` : `No agent named "${to}" is running. ${reach}` };
246
+ }
247
+
248
+ // Two agents that each answer every message would talk for ever.
249
+ const key = `${from.id}>${target.id}`;
250
+ const recent = (sends.get(key) || []).filter(t => now - t < PAIR_WINDOW_MS);
251
+ if (recent.length >= PAIR_LIMIT) {
252
+ return { error: `You have sent ${target.name} ${PAIR_LIMIT} messages in the last ${PAIR_WINDOW_MS / 60000} minutes, which is the limit. Tell the user what you need from ${target.name} instead.` };
253
+ }
254
+ const queue = queues.get(target.id) || [];
255
+ if (queue.length - (serverAhead.get(target.id) || 0) >= QUEUE_CAP) {
256
+ return { error: `${target.name} already has ${QUEUE_CAP} messages waiting for it. Try again once it has caught up.` };
257
+ }
258
+
259
+ recent.push(now);
260
+ sends.set(key, recent);
261
+ queue.push(formatMessage(from, body));
262
+ queues.set(target.id, queue);
263
+ return flushMessages(target, now) ? { delivered: true, to: target } : { queued: queue.length, to: target };
264
+ }
265
+
266
+ // Type the next waiting message into this session if it can take one now.
267
+ // Returns whether one was written. Called on every state check, so cheap when
268
+ // the queue is empty.
269
+ export function flushMessages(session, now = Date.now()) {
270
+ const queue = queues.get(session.id);
271
+ if (!queue?.length || !canDeliver(session, now)) return false;
272
+ deliver(session, queue.shift(), now);
273
+ const ahead = serverAhead.get(session.id) || 0;
274
+ if (ahead) serverAhead.set(session.id, ahead - 1);
275
+ if (!queue.length) queues.delete(session.id);
276
+ return true;
277
+ }
278
+
279
+ export function pendingMessages(sessionId) {
280
+ return queues.get(sessionId)?.length || 0;
281
+ }
282
+
283
+ // What list_agents shows about each agent `from` can reach. Picked, not
284
+ // spread: a session carries its pty and its board token. The job title comes
285
+ // in as a function so this module stays clear of the job store.
286
+ export function agentSummaries(from, sessions, jobTitle = () => null) {
287
+ return messageableAgents(from, sessions).map(s => ({
288
+ name: s.name, agent: s.agent, repoSlug: s.repoSlug, branchName: s.branchName, state: s.state,
289
+ jobTitle: s.jobId ? jobTitle(s.jobId) : null,
290
+ pending: pendingMessages(s.id),
291
+ }));
292
+ }
293
+
294
+ // The recipient is gone. Sessions do not survive a restart, so neither does
295
+ // anything addressed to one.
296
+ export function dropMessages(sessionId) {
297
+ queues.delete(sessionId);
298
+ serverAhead.delete(sessionId);
299
+ for (const key of sends.keys()) {
300
+ if (key.startsWith(`${sessionId}>`) || key.endsWith(`>${sessionId}`)) sends.delete(key);
301
+ }
302
+ }
303
+
304
+ // Whether a pty-input write is someone typing, as opposed to the terminal
305
+ // answering a query or reporting focus. Only those replies are left out;
306
+ // anything else counts, arrow keys and Tab included, since up-arrow recalls a
307
+ // history line into the composer as surely as typing it would.
308
+ const TERMINAL_REPLY_RE = new RegExp([
309
+ /\x1b\[[IO]/.source, // focus in / out
310
+ /\x1b\[\d+;\d+R/.source, // cursor position report
311
+ /\x1b\[[?>=][\d;]*c/.source, // device attributes
312
+ /\x1b\[\?[\d;]*\$y/.source, // mode report
313
+ /\x1b\][^\x07\x1b]*(\x07|\x1b\\)/.source, // OSC: colour and title replies
314
+ /\x1bP[^\x1b]*\x1b\\/.source, // DCS: terminal version and the like
315
+ ].join('|'), 'g');
316
+
317
+ export function isTyping(data) {
318
+ return String(data).replace(TERMINAL_REPLY_RE, '').length > 0;
319
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code's PermissionRequest hook for a worker on one of Billion's cards
3
+ // (docs/BILLION.md, part 4). Runs as its own process whenever the worker is
4
+ // about to show a permission dialog: it hands the request to the Agent 007
5
+ // server, which asks Billion, and prints Billion's answer.
6
+ //
7
+ // Anything short of a clear answer prints nothing, and printing nothing means
8
+ // "no decision": the dialog appears for a person as it always did. So a
9
+ // server that is down, slow or confused can never approve anything.
10
+
11
+ import { readFileSync } from 'fs';
12
+ import { MCP_SERVER_NAME, HOOK_WAIT_MS } from './agent-mcp.js';
13
+
14
+ // The worker's own board MCP config (server/agent-mcp.js), passed as the one
15
+ // argument: it already holds the board's address and this agent's token, in a
16
+ // 0600 file. Not an environment variable — every process the agent starts
17
+ // would inherit that.
18
+ function board(configPath) {
19
+ try {
20
+ const server = JSON.parse(readFileSync(configPath, 'utf8')).mcpServers[MCP_SERVER_NAME];
21
+ return { url: server.url.replace(/\/mcp$/, '/hook/permission'), auth: server.headers.Authorization };
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ const target = board(process.argv[2]);
27
+
28
+ let input = '';
29
+ process.stdin.setEncoding('utf8');
30
+ process.stdin.on('data', (d) => { input += d; });
31
+ process.stdin.on('end', async () => {
32
+ if (!target) return;
33
+ try {
34
+ const res = await fetch(target.url, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json', Authorization: target.auth },
37
+ body: input,
38
+ signal: AbortSignal.timeout(HOOK_WAIT_MS),
39
+ });
40
+ if (!res.ok) return;
41
+ const body = await res.json();
42
+ const behavior = body?.hookSpecificOutput?.decision?.behavior;
43
+ if (behavior === 'allow' || behavior === 'deny') process.stdout.write(JSON.stringify(body));
44
+ } catch {
45
+ // No decision.
46
+ }
47
+ });