@bill10/agent-007 0.6.4001 → 0.6.6000
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/.env.example +14 -4
- package/README.md +5 -2
- package/VERSION +1 -1
- package/bin/agent-007.js +4 -2
- package/package.json +1 -1
- package/public/app.js +2 -1
- package/public/modules/explorer.js +27 -1
- package/public/modules/state.js +4 -0
- package/public/style.css +7 -1
- package/server/claude-trust.js +14 -2
- package/server/http.js +4 -0
- package/server/mcp.js +26 -1
- package/server/owner.js +219 -0
- package/server/pty.js +4 -2
- package/server/ws.js +6 -0
- package/server.js +7 -2
- package/templates/billion/charter.md +6 -0
package/.env.example
CHANGED
|
@@ -42,13 +42,23 @@
|
|
|
42
42
|
# Billion's own folder and git repo. Must be new or empty the first time.
|
|
43
43
|
# BILLION_DIR=~/.agent-007/billion
|
|
44
44
|
|
|
45
|
+
# Billion on your phone: its notify_owner questions are sent to you over
|
|
46
|
+
# Telegram, and what you answer there is typed into its terminal as
|
|
47
|
+
# "[Owner via Telegram] ...". Make a bot with @BotFather (/newbot) and paste its
|
|
48
|
+
# token here; message the bot once and restart, and the server log shows your
|
|
49
|
+
# chat id. Only messages from that chat reach Billion. Unset: off.
|
|
50
|
+
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
|
51
|
+
# TELEGRAM_CHAT_ID=123456789
|
|
52
|
+
|
|
45
53
|
# Claude Code stops at a "do you trust this folder?" dialog the first time it
|
|
46
54
|
# runs anywhere, and every job-board worker gets a brand-new worktree. By default
|
|
47
55
|
# the app accepts it for board workers (it adds the worktree to ~/.claude.json
|
|
48
56
|
# before the spawn, and answers "Yes" if the dialog still appears), so queued
|
|
49
|
-
# jobs start on their own.
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
57
|
+
# jobs start on their own. Codex asks the same, and its board workers are
|
|
58
|
+
# started with that one worktree trusted for the run (-c projects=...; nothing
|
|
59
|
+
# is written to ~/.codex/config.toml). A trusted worktree loads that repo's own
|
|
60
|
+
# .claude/settings.json, or Codex's project config, hooks and exec policies:
|
|
61
|
+
# its hooks run, and its permission allow rules can pre-approve what a strict
|
|
62
|
+
# board mode would ask about. 0/false/off/no keeps the dialog for both.
|
|
53
63
|
# Hand-started agents always keep it.
|
|
54
64
|
# TRUST_BOARD_WORKTREES=0
|
package/README.md
CHANGED
|
@@ -51,10 +51,11 @@ Uncomment what you want, then restart. The ones people change:
|
|
|
51
51
|
| Setting | What it does |
|
|
52
52
|
|---------|--------------|
|
|
53
53
|
| `BILLION=0` | Turns off Billion, the always-on agent |
|
|
54
|
+
| `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` | Billion's questions reach your phone, and you answer from there ([setup](docs/BILLION.md#telegram)) |
|
|
54
55
|
| `HOST=0.0.0.0` + `ALLOWED_ORIGINS=<tailnet name>` | Reach it from your phone or another machine (behind Tailscale only, see [docs/REMOTE.md](docs/REMOTE.md)) |
|
|
55
56
|
| `CLAUDE_PERMISSION_MODE` | Mode Claude Code agents start in, e.g. `bypassPermissions` |
|
|
56
57
|
| `CODEX_PERMISSION_MODE` | The same for Codex |
|
|
57
|
-
| `TRUST_BOARD_WORKTREES=0` | Keeps Claude Code's folder-trust prompt for job board workers |
|
|
58
|
+
| `TRUST_BOARD_WORKTREES=0` | Keeps Claude Code's and Codex's folder-trust prompt for job board workers |
|
|
58
59
|
| `PORT` | Port to listen on (default `7007`) |
|
|
59
60
|
|
|
60
61
|
Highest wins: command-line flags (`--port`), then environment variables, then
|
|
@@ -121,7 +122,9 @@ ALLOWED_ORIGINS=mac-mini.tailXXXX.ts.net npm start # Allow a remote browser or
|
|
|
121
122
|
| `AGENT_MESSAGING` | *(guarded)* | `open` lets any of your agents message any other. By default an agent that asks before acting cannot message one that never asks |
|
|
122
123
|
| `BILLION` | *(on)* | `0` (or `false`/`off`/`no`) turns Billion off. It is also off whenever user accounts exist, since it would belong to everyone |
|
|
123
124
|
| `BILLION_DIR` | `~/.agent-007/billion` | Billion's own folder and git repo. Point it at a new or empty folder |
|
|
124
|
-
| `
|
|
125
|
+
| `TELEGRAM_BOT_TOKEN` | *(off)* | A Telegram bot's token. Billion's `notify_owner` questions are sent through it, and replies come back into Billion's terminal. See [docs/BILLION.md](docs/BILLION.md#telegram) |
|
|
126
|
+
| `TELEGRAM_CHAT_ID` | *(none)* | Your chat with the bot. The only chat whose messages reach Billion; unset, the server logs the id of the first chat that messages the bot |
|
|
127
|
+
| `TRUST_BOARD_WORKTREES` | *(on)* | Board-dispatched Claude Code and Codex workers skip the workspace-trust dialog, so queued jobs start unattended. That also lets the repo's own `.claude/settings.json` (or Codex project config, hooks and exec policies) apply without asking. `0` (or `false`/`off`/`no`) keeps the dialog. Hand-started agents always keep it |
|
|
125
128
|
|
|
126
129
|
> **Running remotely?** The server spawns real shells, so never expose it to the
|
|
127
130
|
> open internet. See [docs/REMOTE.md](docs/REMOTE.md) for the recommended
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.6.0
|
package/bin/agent-007.js
CHANGED
|
@@ -41,8 +41,10 @@ Settings (default in brackets):
|
|
|
41
41
|
AGENT_MESSAGING open = any agent may message any other [guarded]
|
|
42
42
|
BILLION 0 turns off Billion, the always-on agent [on]
|
|
43
43
|
BILLION_DIR Billion's folder and repo [~/.agent-007/billion]
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
TELEGRAM_BOT_TOKEN Bot token for Billion's questions on your phone [off]
|
|
45
|
+
TELEGRAM_CHAT_ID Your chat with that bot; only it reaches Billion [none]
|
|
46
|
+
TRUST_BOARD_WORKTREES 0 keeps Claude Code's and Codex's folder-trust
|
|
47
|
+
prompt for job board workers [on]
|
|
46
48
|
|
|
47
49
|
Set them in the environment, in ~/.agent-007/.env (\`agent-007 init\` writes it,
|
|
48
50
|
every setting explained and commented out), or in a .env in the current
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bill10/agent-007",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6000",
|
|
4
4
|
"description": "From web terminals for your coding agents to a self-running agent company: Claude Code and Codex in parallel git worktrees, a job board they pick work from, and one agent that runs the board from a goal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/public/app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Main init + message routing
|
|
2
|
-
import { agents, repos, selfUserId, setSelf, shellPreset, setView, setBillionEnabled } from './modules/state.js';
|
|
2
|
+
import { agents, repos, selfUserId, setSelf, shellPreset, setView, setBillionEnabled, setWaitingItems } from './modules/state.js';
|
|
3
3
|
import { connect, send } from './modules/ws.js';
|
|
4
4
|
import {
|
|
5
5
|
handleSessionCreated, handlePtyOutput, handlePtySize, handleStateChange,
|
|
@@ -533,6 +533,7 @@ function onMessage(msg) {
|
|
|
533
533
|
case 'file-diff': handleFileDiff(msg); break;
|
|
534
534
|
case 'full-tree': handleFullTree(msg); break;
|
|
535
535
|
case 'orphans-list': handleOrphansList(msg); break;
|
|
536
|
+
case 'waiting-list': setWaitingItems(msg.items); renderExplorer(); break;
|
|
536
537
|
// The office canvas pins one paper per job, so a jobs-list broadcast has
|
|
537
538
|
// to repaint it too — the animation loop skips frames with no live agent.
|
|
538
539
|
case 'jobs-list': handleJobsList(msg); noteJobsUpdate(); renderOffice(); break;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// File explorer panel — repo sections, branch trees, inline diffs
|
|
2
|
-
import { agents, repos, orphans, activeSessionId, setView, billionEnabled } from './state.js';
|
|
2
|
+
import { agents, repos, orphans, activeSessionId, setView, billionEnabled, waitingItems } from './state.js';
|
|
3
3
|
import { send } from './ws.js';
|
|
4
4
|
import { switchToSession } from './terminal.js';
|
|
5
5
|
|
|
@@ -326,6 +326,32 @@ function renderBillionRow(content) {
|
|
|
326
326
|
entry.appendChild(start);
|
|
327
327
|
}
|
|
328
328
|
section.appendChild(entry);
|
|
329
|
+
// What Billion asked the owner with notify_owner, until they dismiss it.
|
|
330
|
+
if (waitingItems.length) {
|
|
331
|
+
const list = document.createElement('ul');
|
|
332
|
+
list.className = 'explorer-waiting';
|
|
333
|
+
list.setAttribute('aria-label', 'Waiting on you');
|
|
334
|
+
const head = document.createElement('li');
|
|
335
|
+
head.className = 'explorer-waiting-head';
|
|
336
|
+
head.textContent = 'Waiting on you';
|
|
337
|
+
list.appendChild(head);
|
|
338
|
+
for (const item of waitingItems) {
|
|
339
|
+
const li = document.createElement('li');
|
|
340
|
+
li.className = 'explorer-waiting-item';
|
|
341
|
+
const text = document.createElement('span');
|
|
342
|
+
text.textContent = item.text;
|
|
343
|
+
text.title = item.text;
|
|
344
|
+
const dismiss = document.createElement('button');
|
|
345
|
+
dismiss.className = 'explorer-icon-btn';
|
|
346
|
+
dismiss.textContent = '\u00d7';
|
|
347
|
+
dismiss.title = 'Dismiss';
|
|
348
|
+
dismiss.setAttribute('aria-label', 'Dismiss');
|
|
349
|
+
dismiss.onclick = () => send({ type: 'waiting-dismiss', id: item.id });
|
|
350
|
+
li.append(text, dismiss);
|
|
351
|
+
list.appendChild(li);
|
|
352
|
+
}
|
|
353
|
+
section.appendChild(list);
|
|
354
|
+
}
|
|
329
355
|
content.appendChild(section);
|
|
330
356
|
}
|
|
331
357
|
|
package/public/modules/state.js
CHANGED
|
@@ -28,6 +28,10 @@ export let serverPlatform = ''; // process.platform of the server, from the welc
|
|
|
28
28
|
export let billionEnabled = false;
|
|
29
29
|
export function setBillionEnabled(on) { billionEnabled = !!on; }
|
|
30
30
|
|
|
31
|
+
// Billion's notify_owner messages the owner has not dismissed (server/owner.js).
|
|
32
|
+
export let waitingItems = [];
|
|
33
|
+
export function setWaitingItems(items) { waitingItems = Array.isArray(items) ? items : []; }
|
|
34
|
+
|
|
31
35
|
// Billion's tab first, then the rest in their own order.
|
|
32
36
|
export function billionFirst(entries) {
|
|
33
37
|
return [...entries].sort(([, a], [, b]) => Number(!!b.isBillion) - Number(!!a.isBillion));
|
package/public/style.css
CHANGED
|
@@ -751,6 +751,12 @@ body {
|
|
|
751
751
|
.explorer-billion-name { color: var(--text); font-weight: 600; }
|
|
752
752
|
/* The one way back for a stopped Billion, so it reads as the row's action. */
|
|
753
753
|
.explorer-billion-start { margin-left: auto; color: var(--accent); }
|
|
754
|
+
/* notify_owner's questions, under Billion's row until dismissed */
|
|
755
|
+
.explorer-waiting { list-style: none; margin: 0 0 4px; padding: 0 10px 0 22px; font-size: 11px; }
|
|
756
|
+
.explorer-waiting-head { color: var(--accent); font-weight: 600; padding: 2px 0; }
|
|
757
|
+
.explorer-waiting-item { display: flex; align-items: flex-start; gap: 4px; color: var(--text-muted); padding: 2px 0; }
|
|
758
|
+
.explorer-waiting-item span { flex: 1; min-width: 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
759
|
+
.explorer-waiting-item button { flex-shrink: 0; }
|
|
754
760
|
|
|
755
761
|
.explorer-branch-name {
|
|
756
762
|
overflow: hidden;
|
|
@@ -1175,7 +1181,7 @@ body {
|
|
|
1175
1181
|
.mobile-nav { display: none; }
|
|
1176
1182
|
@media (max-width: 700px) {
|
|
1177
1183
|
.divider, .explorer-reopen-btn, #btn-toggle-explorer { display: none !important; }
|
|
1178
|
-
.explorer-billion-start { min-width: 44px; min-height: 44px; }
|
|
1184
|
+
.explorer-billion-start, .explorer-waiting-item button { min-width: 44px; min-height: 44px; }
|
|
1179
1185
|
#explorer-panel, #office-panel, #terminal-panel {
|
|
1180
1186
|
display: none; width: 100% !important; min-width: 0; max-width: none; flex: 1;
|
|
1181
1187
|
}
|
package/server/claude-trust.js
CHANGED
|
@@ -18,14 +18,26 @@ import { join } from 'path';
|
|
|
18
18
|
import { sessionAgentFromCommand } from '../lib/jobs.js';
|
|
19
19
|
import { envSwitchOn } from '../lib/helpers.js';
|
|
20
20
|
|
|
21
|
-
// Board-dispatched Claude Code workers only, and on unless
|
|
21
|
+
// Board-dispatched Claude Code and Codex workers only, and on unless
|
|
22
22
|
// TRUST_BOARD_WORKTREES says otherwise (0/false/off/no). Hand-started agents
|
|
23
23
|
// keep the dialog.
|
|
24
24
|
export function autoTrusts({ spawnedBy, worktreePath, command }, env = process.env) {
|
|
25
|
-
return spawnedBy === 'board' && !!worktreePath && sessionAgentFromCommand(command)
|
|
25
|
+
return spawnedBy === 'board' && !!worktreePath && ['claude', 'codex'].includes(sessionAgentFromCommand(command))
|
|
26
26
|
&& envSwitchOn(env.TRUST_BOARD_WORKTREES);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Codex asks "Do you trust the contents of this directory?" in a new worktree
|
|
30
|
+
// too, and records the answer as [projects."<path>"] trust_level in
|
|
31
|
+
// ~/.codex/config.toml. A -c override does the same for this one run and
|
|
32
|
+
// writes nothing: an inline table merges with the user's own [projects]
|
|
33
|
+
// (verified against codex-cli 0.154), and unlike a dotted key it survives the
|
|
34
|
+
// dot in ~/.agent-007. A JSON string is a valid TOML basic string.
|
|
35
|
+
export function codexTrustArgs(folder) {
|
|
36
|
+
let path = folder;
|
|
37
|
+
try { path = realpathSync(folder); } catch {}
|
|
38
|
+
return ['-c', `projects={${JSON.stringify(path)}={trust_level="trusted"}}`];
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
30
42
|
|
|
31
43
|
// True when the entry is there afterwards. Never throws. Workers inherit the
|
package/server/http.js
CHANGED
|
@@ -17,6 +17,7 @@ import { expandHome } from '../lib/helpers.js';
|
|
|
17
17
|
import { requestApproval, answerApproval } from './approvals.js';
|
|
18
18
|
import { agentSummaries, sendMessage, flushMessages, pendingMessages } from './messages.js';
|
|
19
19
|
import { handleMcpMessage } from './mcp.js';
|
|
20
|
+
import { notifyOwner } from './owner.js';
|
|
20
21
|
|
|
21
22
|
// --- Origin Check Middleware (B2) ---
|
|
22
23
|
// Rejects cross-origin requests from disallowed origins. localhost is always
|
|
@@ -129,6 +130,9 @@ export function setupRoutes(app, staticDir, { broadcast, killSession } = {}) {
|
|
|
129
130
|
answerPermission: ({ id, decision, reason }) => (req.agentSession.isBillion
|
|
130
131
|
? answerApproval(id, decision, reason)
|
|
131
132
|
: { error: 'Only Billion answers permission requests.' }),
|
|
133
|
+
notifyOwner: (text) => (req.agentSession.isBillion
|
|
134
|
+
? notifyOwner(text, { broadcast })
|
|
135
|
+
: { error: 'Only Billion can notify the owner.' }),
|
|
132
136
|
billionReady: () => {
|
|
133
137
|
const session = req.agentSession;
|
|
134
138
|
if (!session.isBillion) return { error: 'Only Billion has an inbox to open.' };
|
package/server/mcp.js
CHANGED
|
@@ -321,8 +321,27 @@ export const ANSWER_PERMISSION_TOOL = {
|
|
|
321
321
|
},
|
|
322
322
|
};
|
|
323
323
|
|
|
324
|
+
export const NOTIFY_OWNER_TOOL = {
|
|
325
|
+
name: 'notify_owner',
|
|
326
|
+
description:
|
|
327
|
+
'Put a question or a decision in front of the owner when they may be away '
|
|
328
|
+
+ 'from the terminal: it is pinned in the "Waiting on you" list at the top of '
|
|
329
|
+
+ 'the owner\'s browser, and sent to their phone over Telegram when that is set '
|
|
330
|
+
+ 'up. One short message: the question, why, and what you recommend. Their '
|
|
331
|
+
+ 'reply, if they answer from Telegram, arrives in this terminal as '
|
|
332
|
+
+ '"[Owner via Telegram] <text>". At most a few per minute.',
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: 'object',
|
|
335
|
+
properties: {
|
|
336
|
+
text: { type: 'string', description: 'The message, written to be read on a phone.' },
|
|
337
|
+
},
|
|
338
|
+
required: ['text'],
|
|
339
|
+
additionalProperties: false,
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
324
343
|
export const TOOLS = [POST_JOB_TOOL, LIST_JOBS_TOOL, READ_JOB_TOOL, EDIT_JOB_TOOL, FINISH_JOB_TOOL, LIST_AGENTS_TOOL, SEND_MESSAGE_TOOL];
|
|
325
|
-
const BILLION_TOOLS = [BILLION_READY_TOOL, ADD_REPO_TOOL, CLOSE_JOB_TOOL, ANSWER_PERMISSION_TOOL];
|
|
344
|
+
const BILLION_TOOLS = [BILLION_READY_TOOL, ADD_REPO_TOOL, CLOSE_JOB_TOOL, ANSWER_PERMISSION_TOOL, NOTIFY_OWNER_TOOL];
|
|
326
345
|
|
|
327
346
|
export function toolsFor(session) {
|
|
328
347
|
return session?.isBillion ? [...TOOLS, ...BILLION_TOOLS] : TOOLS;
|
|
@@ -522,6 +541,12 @@ const CALLS = {
|
|
|
522
541
|
: `${result.worker} has your answer: ${result.choice}.`);
|
|
523
542
|
},
|
|
524
543
|
|
|
544
|
+
[NOTIFY_OWNER_TOOL.name]: async (args, ctx) => {
|
|
545
|
+
const result = ctx.notifyOwner ? await ctx.notifyOwner(args.text) : { error: 'Only Billion can notify the owner.' };
|
|
546
|
+
if (result.error) return toolText(result.error, true);
|
|
547
|
+
return toolText('Sent to the owner on Telegram and pinned under "Waiting on you". Keep working on everything else; their reply, if any, arrives here as [Owner via Telegram].');
|
|
548
|
+
},
|
|
549
|
+
|
|
525
550
|
[LIST_AGENTS_TOOL.name]: (args, ctx) => {
|
|
526
551
|
const agents = ctx.listAgents();
|
|
527
552
|
if (!agents.length) return toolText('No other agents are running that you can message.');
|
package/server/owner.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Reaching the owner when they are away from the terminal: Billion's
|
|
2
|
+
// notify_owner tool, the "Waiting on you" list it pins in the browser, and a
|
|
3
|
+
// Telegram bot that carries both ways (docs/BILLION.md, "Telegram").
|
|
4
|
+
//
|
|
5
|
+
// Telegram is optional: with TELEGRAM_BOT_TOKEN unset nothing here talks to
|
|
6
|
+
// the network and the list still works. Plain fetch against the Bot API, no
|
|
7
|
+
// library. The one gate on owner input is the chat id: a message from any
|
|
8
|
+
// other chat is dropped without a word, since anyone can find and message a
|
|
9
|
+
// bot. The token is a password to the bot, so it is never logged and never
|
|
10
|
+
// sent to a browser: every error that leaves this module goes through redact().
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, renameSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { randomUUID } from 'crypto';
|
|
15
|
+
import { CONFIG_DIR } from './state.js';
|
|
16
|
+
import { liveBillion } from './billion.js';
|
|
17
|
+
import { sendText } from './messages.js';
|
|
18
|
+
|
|
19
|
+
export const MAX_NOTIFY_CHARS = 3000; // Telegram's own limit is 4096
|
|
20
|
+
export const NOTIFY_LIMIT = 5; // a burst of five, then...
|
|
21
|
+
export const NOTIFY_WINDOW_MS = 60 * 1000; // ...five a minute at most
|
|
22
|
+
export const POLL_TIMEOUT_S = 30;
|
|
23
|
+
const MAX_BACKOFF_MS = 60 * 1000;
|
|
24
|
+
const WAITING_CAP = 50;
|
|
25
|
+
export const OWNER_PREFIX = '[Owner via Telegram]';
|
|
26
|
+
|
|
27
|
+
export function telegramSettings(env = process.env) {
|
|
28
|
+
const token = (env.TELEGRAM_BOT_TOKEN || '').trim();
|
|
29
|
+
const chatId = (env.TELEGRAM_CHAT_ID || '').trim();
|
|
30
|
+
return { token, chatId };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function redact(text, env = process.env) {
|
|
34
|
+
const { token } = telegramSettings(env);
|
|
35
|
+
let out = String(text);
|
|
36
|
+
// Raw, and as a URL would carry it (the colon escaped).
|
|
37
|
+
for (const form of token ? [token, encodeURIComponent(token)] : []) out = out.split(form).join('<token>');
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// One Bot API call. Returns the result, or throws an Error whose message is
|
|
42
|
+
// already redacted.
|
|
43
|
+
async function call(method, params, { env = process.env, signal } = {}) {
|
|
44
|
+
const { token } = telegramSettings(env);
|
|
45
|
+
let body;
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify(params),
|
|
51
|
+
signal,
|
|
52
|
+
});
|
|
53
|
+
body = await res.json().catch(() => ({ ok: false, description: `HTTP ${res.status}` }));
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw new Error(redact(err.cause?.message || err.message, env));
|
|
56
|
+
}
|
|
57
|
+
if (!body?.ok) throw new Error(redact(body?.description || 'Telegram said no', env));
|
|
58
|
+
return body.result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function sendTelegram(text, { env = process.env } = {}) {
|
|
62
|
+
const { token, chatId } = telegramSettings(env);
|
|
63
|
+
if (!token || !chatId) return { error: 'Telegram is not configured' };
|
|
64
|
+
try {
|
|
65
|
+
await call('sendMessage', { chat_id: chatId, text }, { env });
|
|
66
|
+
return { ok: true };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return { error: err.message };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- The "Waiting on you" list, in the config dir so it survives restarts ---
|
|
73
|
+
|
|
74
|
+
const waitingPath = () => join(CONFIG_DIR, 'waiting.json');
|
|
75
|
+
|
|
76
|
+
export function waitingItems() {
|
|
77
|
+
try {
|
|
78
|
+
const items = JSON.parse(readFileSync(waitingPath(), 'utf8'));
|
|
79
|
+
return Array.isArray(items) ? items : [];
|
|
80
|
+
} catch { return []; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function saveWaiting(items) {
|
|
84
|
+
const tmp = `${waitingPath()}.tmp`;
|
|
85
|
+
writeFileSync(tmp, JSON.stringify(items, null, 2));
|
|
86
|
+
renameSync(tmp, waitingPath());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const waitingPayload = () => ({ type: 'waiting-list', items: waitingItems() });
|
|
90
|
+
|
|
91
|
+
export function addWaiting(text, broadcast, now = Date.now()) {
|
|
92
|
+
const items = [...waitingItems(), { id: randomUUID(), text, at: new Date(now).toISOString() }].slice(-WAITING_CAP);
|
|
93
|
+
saveWaiting(items);
|
|
94
|
+
broadcast?.(waitingPayload());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function dismissWaiting(id, broadcast) {
|
|
98
|
+
const items = waitingItems();
|
|
99
|
+
const kept = items.filter(item => item.id !== id);
|
|
100
|
+
if (kept.length === items.length) return false;
|
|
101
|
+
saveWaiting(kept);
|
|
102
|
+
broadcast?.(waitingPayload());
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- notify_owner ---
|
|
107
|
+
|
|
108
|
+
let sent = []; // times of recent notify_owner calls
|
|
109
|
+
|
|
110
|
+
export async function notifyOwner(text, { broadcast, env = process.env, now = Date.now() } = {}) {
|
|
111
|
+
const body = typeof text === 'string' ? text.trim() : '';
|
|
112
|
+
if (!body) return { error: 'The message is empty.' };
|
|
113
|
+
if (body.length > MAX_NOTIFY_CHARS) return { error: `The message is ${body.length} characters; keep it under ${MAX_NOTIFY_CHARS}.` };
|
|
114
|
+
sent = sent.filter(t => now - t < NOTIFY_WINDOW_MS);
|
|
115
|
+
if (sent.length >= NOTIFY_LIMIT) {
|
|
116
|
+
return { error: `Not sent: you have notified the owner ${NOTIFY_LIMIT} times in the last minute. Put the rest in one message later, or under Waiting on you in STATE.md.` };
|
|
117
|
+
}
|
|
118
|
+
sent.push(now);
|
|
119
|
+
try { addWaiting(body, broadcast, now); } catch (err) {
|
|
120
|
+
console.error('Could not save the Waiting on you list:', err.message);
|
|
121
|
+
}
|
|
122
|
+
const { token, chatId } = telegramSettings(env);
|
|
123
|
+
if (!token || !chatId) {
|
|
124
|
+
return { pinned: true, error: 'Pinned under "Waiting on you" in the owner\'s browser, but not sent to their phone: Telegram is not configured (TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID). Say it in your terminal as well.' };
|
|
125
|
+
}
|
|
126
|
+
const result = await sendTelegram(`Billion: ${body}`, { env });
|
|
127
|
+
if (result.error) return { pinned: true, error: `Pinned under "Waiting on you" in the owner's browser, but the Telegram send failed: ${result.error}` };
|
|
128
|
+
return { ok: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Replies: long-polling getUpdates ---
|
|
132
|
+
|
|
133
|
+
const discovered = new Set(); // chat ids already shown, so a stranger's first message cannot hide the owner's
|
|
134
|
+
|
|
135
|
+
// One update. Returns what happened, for the tests and the log.
|
|
136
|
+
export async function handleUpdate(update, { broadcast, env = process.env } = {}) {
|
|
137
|
+
const msg = update?.message;
|
|
138
|
+
const chat = msg?.chat?.id;
|
|
139
|
+
if (chat === undefined || chat === null) return 'ignored';
|
|
140
|
+
const { chatId } = telegramSettings(env);
|
|
141
|
+
if (!chatId) {
|
|
142
|
+
// Setting up: say whose chat this was, and use nothing else from it. Every
|
|
143
|
+
// new chat is shown, not only the first.
|
|
144
|
+
if (!discovered.has(chat) && discovered.size < 20) {
|
|
145
|
+
discovered.add(chat);
|
|
146
|
+
const line = `Telegram: a message came from chat ${chat}. If that was you, set TELEGRAM_CHAT_ID=${chat} in ~/.agent-007/.env and restart.`;
|
|
147
|
+
console.log(` ${line}`);
|
|
148
|
+
broadcast?.({ type: 'notification', level: 'info', message: line });
|
|
149
|
+
}
|
|
150
|
+
return 'discovery';
|
|
151
|
+
}
|
|
152
|
+
if (String(chat) !== chatId) return 'ignored';
|
|
153
|
+
if (typeof msg.text !== 'string' || !msg.text.trim()) return 'ignored';
|
|
154
|
+
const billion = liveBillion();
|
|
155
|
+
if (!billion) {
|
|
156
|
+
await sendTelegram('Billion is not running', { env });
|
|
157
|
+
return 'not-running';
|
|
158
|
+
}
|
|
159
|
+
if (!sendText(billion, `${OWNER_PREFIX} ${msg.text}`)) {
|
|
160
|
+
await sendTelegram('Billion has too much waiting for it; try again in a while.', { env });
|
|
161
|
+
return 'full';
|
|
162
|
+
}
|
|
163
|
+
return 'delivered';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// One getUpdates round. Returns the next offset.
|
|
167
|
+
export async function pollOnce(offset, { broadcast, env = process.env, signal } = {}) {
|
|
168
|
+
const params = { timeout: POLL_TIMEOUT_S, allowed_updates: ['message'] };
|
|
169
|
+
if (offset) params.offset = offset;
|
|
170
|
+
// Longer than Telegram's own wait, so a dead connection still gives up.
|
|
171
|
+
const deadline = AbortSignal.timeout((POLL_TIMEOUT_S + 15) * 1000);
|
|
172
|
+
const updates = await call('getUpdates', params, { env, signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
|
|
173
|
+
let next = offset;
|
|
174
|
+
for (const update of updates || []) {
|
|
175
|
+
// Past it before handling, so an update that throws is not fetched again for ever.
|
|
176
|
+
next = Math.max(next || 0, update.update_id + 1);
|
|
177
|
+
try { await handleUpdate(update, { broadcast, env }); } catch (err) {
|
|
178
|
+
console.error('Telegram: could not handle a message:', redact(err.message, env));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let stopper = null;
|
|
185
|
+
|
|
186
|
+
const pause = (ms, signal) => new Promise(resolve => {
|
|
187
|
+
const timer = setTimeout(resolve, ms);
|
|
188
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export function startTelegram({ broadcast, env = process.env } = {}) {
|
|
192
|
+
const { token, chatId } = telegramSettings(env);
|
|
193
|
+
if (!token || stopper) return false;
|
|
194
|
+
if (!chatId) console.log(' Telegram: send any message to your bot, then set TELEGRAM_CHAT_ID=<id> (the id is shown here when it arrives)');
|
|
195
|
+
else console.log(' Telegram: on');
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
stopper = controller;
|
|
198
|
+
(async () => {
|
|
199
|
+
let offset = 0;
|
|
200
|
+
let backoff = 1000;
|
|
201
|
+
while (!controller.signal.aborted) {
|
|
202
|
+
try {
|
|
203
|
+
offset = await pollOnce(offset, { broadcast, env, signal: controller.signal });
|
|
204
|
+
backoff = 1000;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (controller.signal.aborted) break;
|
|
207
|
+
console.error(`Telegram: getUpdates failed (${err.message}); retrying in ${backoff / 1000}s`);
|
|
208
|
+
await pause(backoff, controller.signal);
|
|
209
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function stopTelegram() {
|
|
217
|
+
stopper?.abort();
|
|
218
|
+
stopper = null;
|
|
219
|
+
}
|
package/server/pty.js
CHANGED
|
@@ -14,6 +14,7 @@ import { broadcastJobs } from './jobs.js';
|
|
|
14
14
|
import { flushMessages, dropMessages } from './messages.js';
|
|
15
15
|
import { sessionAgentFromCommand, permissionFlagsFromCommand } from '../lib/jobs.js';
|
|
16
16
|
import { trustDialogKey } from './billion.js';
|
|
17
|
+
import { codexTrustArgs } from './claude-trust.js';
|
|
17
18
|
import { dropApprovals } from './approvals.js';
|
|
18
19
|
|
|
19
20
|
// Regex constants for output filtering (shared, not recreated per event)
|
|
@@ -234,7 +235,8 @@ export function createSessionFromConfig({ sessionId, name, color, command, repoP
|
|
|
234
235
|
// would put a live board credential on disk for terminals that have no way to
|
|
235
236
|
// use it — a plain `bash` tab does not need one.
|
|
236
237
|
const mcpConfigPath = takesMcpConfig(file) ? writeMcpConfig(sessionId, agentToken) : null;
|
|
237
|
-
const
|
|
238
|
+
const codexTrust = autoTrust && sessionAgentFromCommand(command) === 'codex';
|
|
239
|
+
const mcpArgs = withMcpConfig(file, codexTrust ? [...codexTrustArgs(worktreePath), ...args] : args, mcpConfigPath);
|
|
238
240
|
// A worker on one of Billion's cards asks Billion before it asks a person
|
|
239
241
|
// (server/approvals.js) — where the CLI can be hooked, which today is
|
|
240
242
|
// Claude Code only. Recorded as whether the hook actually went in.
|
|
@@ -308,7 +310,7 @@ export function createSessionFromConfig({ sessionId, name, color, command, repoP
|
|
|
308
310
|
spawnedBy: spawnedBy || 'user',
|
|
309
311
|
jobId: jobId || null, // job this session was dispatched for, if any
|
|
310
312
|
isBillion: !!isBillion, // the one agent you talk to (server/billion.js)
|
|
311
|
-
answersTrust: !!(isBillion || autoTrust), // see answerTrustDialog
|
|
313
|
+
answersTrust: !!(isBillion || (autoTrust && !codexTrust)), // see answerTrustDialog; Codex's flag leaves nothing to answer
|
|
312
314
|
approvalsToBillion: hooked, // its permission dialogs go to Billion first
|
|
313
315
|
exited: false,
|
|
314
316
|
stateCheckInterval: null,
|
package/server/ws.js
CHANGED
|
@@ -12,6 +12,7 @@ import { saveActiveSession, syncOrphansToConfig, saveConfig } from './config.js'
|
|
|
12
12
|
import { addRepo, removeRepo, scanFileTree, startTreeScanLoop, getDiff, broadcastReposList, gitExec, deleteBranch } from './git.js';
|
|
13
13
|
import { createSessionFromConfig } from './pty.js';
|
|
14
14
|
import { isTyping } from './messages.js';
|
|
15
|
+
import { waitingPayload, dismissWaiting } from './owner.js';
|
|
15
16
|
import { parseGitStatus, buildFileTree, safeFilename } from '../lib/helpers.js';
|
|
16
17
|
import { isValidJobAgent } from '../lib/jobs.js';
|
|
17
18
|
import { billionRuns } from './billion.js';
|
|
@@ -195,6 +196,7 @@ export function setupWebSocket(wss, { createSession, killSession, startBillion }
|
|
|
195
196
|
|
|
196
197
|
ws.send(JSON.stringify({ type: 'orphans-list', orphans: [...orphans.values()] }));
|
|
197
198
|
ws.send(JSON.stringify(jobsPayload()));
|
|
199
|
+
ws.send(JSON.stringify(waitingPayload()));
|
|
198
200
|
|
|
199
201
|
broadcastPresence();
|
|
200
202
|
|
|
@@ -268,6 +270,10 @@ export function setupWebSocket(wss, { createSession, killSession, startBillion }
|
|
|
268
270
|
else if (!result.existing) announceSession(result.session, ws);
|
|
269
271
|
break;
|
|
270
272
|
}
|
|
273
|
+
case 'waiting-dismiss': {
|
|
274
|
+
if (typeof msg.id === 'string') dismissWaiting(msg.id, broadcast);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
271
277
|
case 'kill': {
|
|
272
278
|
const session = sessions.get(msg.sessionId);
|
|
273
279
|
if (session && !owns(ws, session.ownerId)) { denyControl(ws, session.name, session.ownerId); break; }
|
package/server.js
CHANGED
|
@@ -32,12 +32,13 @@ import { setupRoutes } from './server/http.js';
|
|
|
32
32
|
import { startDispatcher, stopDispatcher, boardSettings } from './server/jobs.js';
|
|
33
33
|
import { orphans, config } from './server/state.js';
|
|
34
34
|
import { sweepMcpConfigs } from './server/agent-mcp.js';
|
|
35
|
-
import { withDefaultPermission, envPermissionMode, PERMISSION_MODES, ENV_PERMISSION_MODE } from './lib/jobs.js';
|
|
35
|
+
import { withDefaultPermission, envPermissionMode, PERMISSION_MODES, ENV_PERMISSION_MODE, sessionAgentFromCommand } from './lib/jobs.js';
|
|
36
36
|
import { BILLION_NAME, billionEnabled, billionRuns, billionDir, ensureBillionRepo, refreshCharter, suggestProjectsDir, billionCommand, noClaudeCommand } from './server/billion.js';
|
|
37
37
|
import { commandExists, missingCommandMessage } from './server/command-path.js';
|
|
38
38
|
import { parseCommand } from './lib/helpers.js';
|
|
39
39
|
import { hasClaudeTranscript } from './server/agent-transcripts.js';
|
|
40
40
|
import { autoTrusts, trustClaudeFolder } from './server/claude-trust.js';
|
|
41
|
+
import { startTelegram, stopTelegram } from './server/owner.js';
|
|
41
42
|
|
|
42
43
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
44
|
const app = express();
|
|
@@ -100,8 +101,9 @@ async function createSession(command, name, repoPath, customBranch, ownerId, met
|
|
|
100
101
|
|
|
101
102
|
// A board worker's worktree is brand new, so Claude Code would stop at its
|
|
102
103
|
// workspace-trust dialog until someone clicked (server/claude-trust.js).
|
|
104
|
+
// Codex's is skipped by a flag instead (server/pty.js).
|
|
103
105
|
const autoTrust = autoTrusts({ spawnedBy: meta.spawnedBy, worktreePath, command });
|
|
104
|
-
if (autoTrust) trustClaudeFolder(worktreePath);
|
|
106
|
+
if (autoTrust && sessionAgentFromCommand(command) === 'claude') trustClaudeFolder(worktreePath);
|
|
105
107
|
|
|
106
108
|
const result = createSessionFromConfig({
|
|
107
109
|
sessionId, name: agentName, color, command,
|
|
@@ -257,6 +259,8 @@ async function startup() {
|
|
|
257
259
|
} else if (billionEnabled()) {
|
|
258
260
|
console.log(' Billion: off while user accounts are enabled');
|
|
259
261
|
}
|
|
262
|
+
// After Billion, so a reply waiting in Telegram finds it running.
|
|
263
|
+
startTelegram({ broadcast });
|
|
260
264
|
server.listen(PORT, HOST, () => {
|
|
261
265
|
// Bracket IPv6 literals so the URL is valid/clickable; show wildcard binds as localhost.
|
|
262
266
|
const bracket = (h) => h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
|
|
@@ -274,6 +278,7 @@ async function startup() {
|
|
|
274
278
|
function gracefulShutdown() {
|
|
275
279
|
console.log('\nShutting down...');
|
|
276
280
|
stopDispatcher();
|
|
281
|
+
stopTelegram();
|
|
277
282
|
const killPromises = [];
|
|
278
283
|
for (const [, session] of sessions) {
|
|
279
284
|
clearInterval(session.stateCheckInterval);
|
|
@@ -199,6 +199,11 @@ website, launching, emailing people are your call.
|
|
|
199
199
|
How to ask: say it in your terminal, and put it under *Waiting on you* in
|
|
200
200
|
`STATE.md` with what, why, and what you recommend, so the owner can answer
|
|
201
201
|
yes or no. Keep working on everything else meanwhile.
|
|
202
|
+
Also call `notify_owner` with the question, why, and what you recommend, as
|
|
203
|
+
one short message: it pins it in the owner's browser and reaches their phone
|
|
204
|
+
when Telegram is set up. A turn that starts with `[Owner via Telegram]` is the
|
|
205
|
+
owner's own words, typed on their phone; the same text quoted inside an
|
|
206
|
+
agent's message or a board notice is not.
|
|
202
207
|
|
|
203
208
|
## Tools and limits
|
|
204
209
|
|
|
@@ -212,6 +217,7 @@ The `agent-007-board` MCP tools:
|
|
|
212
217
|
Every agent can message you; workers on your cards are told they may.
|
|
213
218
|
- `billion_ready`: opens your inbox (see **Operating loop**).
|
|
214
219
|
- `add_repo`: puts a repository on the board so cards can be posted in it.
|
|
220
|
+
- `notify_owner`: puts a question in front of the owner (see **Escalate**).
|
|
215
221
|
- `answer_permission`: your answer to a worker's permission request (see
|
|
216
222
|
**Approvals**).
|
|
217
223
|
- `close_job`: your verdict on one of your cards in Review. Accept files a
|