@bill10/agent-007 0.9.1001 → 0.9.2001
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 +5 -0
- package/VERSION +1 -1
- package/bin/agent-007.js +2 -0
- package/package.json +1 -1
- package/server/jobs.js +47 -0
- package/server/owner.js +5 -2
- package/server/pty.js +8 -3
- package/server/voice.js +44 -2
package/.env.example
CHANGED
|
@@ -55,6 +55,11 @@
|
|
|
55
55
|
# always speaks every message; never is text only. Speaking needs macOS `say`
|
|
56
56
|
# and ffmpeg; long or link-heavy messages always go as text.
|
|
57
57
|
# TELEGRAM_VOICE=mirror
|
|
58
|
+
# The macOS voice Billion speaks with, as `say -v '?'` lists it. Unset: the best
|
|
59
|
+
# installed English voice, Premium then Enhanced (download them free in System
|
|
60
|
+
# Settings > Accessibility > Spoken Content > System voice > Manage Voices),
|
|
61
|
+
# else say's default. A voice that is not installed is logged and skipped.
|
|
62
|
+
# SAY_VOICE=Ava (Premium)
|
|
58
63
|
# Your voice notes are transcribed with whisper.cpp (brew install whisper-cpp).
|
|
59
64
|
# WHISPER_MODEL is the full path to a ggml model, e.g. ggml-base.en.bin;
|
|
60
65
|
# WHISPER_CPP_BIN only if whisper-cli is not on PATH. Unset: voice notes get a
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.9.
|
|
1
|
+
0.9.2.1
|
package/bin/agent-007.js
CHANGED
|
@@ -45,6 +45,8 @@ Settings (default in brackets):
|
|
|
45
45
|
TELEGRAM_CHAT_ID Your chat with that bot; only it reaches Billion [none]
|
|
46
46
|
TELEGRAM_VOICE mirror, always or never: Billion's messages as
|
|
47
47
|
voice (macOS say + ffmpeg) [mirror]
|
|
48
|
+
SAY_VOICE macOS voice for that, from say -v '?' [best
|
|
49
|
+
installed English Premium/Enhanced voice]
|
|
48
50
|
WHISPER_MODEL whisper.cpp model file; your voice notes are
|
|
49
51
|
transcribed locally [off]
|
|
50
52
|
WHISPER_CPP_BIN whisper.cpp CLI if not on PATH [whisper-cli]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bill10/agent-007",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2001",
|
|
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/server/jobs.js
CHANGED
|
@@ -313,6 +313,7 @@ export function addJob({ title, detail, repoPath, type, schedule, permissionMode
|
|
|
313
313
|
if (written) result.job.attachments = written.attachments;
|
|
314
314
|
allJobs().push(result.job);
|
|
315
315
|
persist(broadcast);
|
|
316
|
+
requestDispatch();
|
|
316
317
|
return { job: result.job };
|
|
317
318
|
}
|
|
318
319
|
|
|
@@ -699,6 +700,7 @@ export async function finishJobForAgent({ session, summary, prUrl }, broadcast,
|
|
|
699
700
|
job.lastErrorAt = null;
|
|
700
701
|
clearPrCheckError(job);
|
|
701
702
|
persist(broadcast);
|
|
703
|
+
requestDispatch();
|
|
702
704
|
if (broadcast) {
|
|
703
705
|
broadcast({
|
|
704
706
|
type: 'notification', level: 'info',
|
|
@@ -876,6 +878,7 @@ export function setJobPaused(jobId, paused, broadcast) {
|
|
|
876
878
|
job.paused = next;
|
|
877
879
|
if (!next && job.schedule) job.nextRunAt = nextCronIso(job.schedule);
|
|
878
880
|
persist(broadcast);
|
|
881
|
+
if (!next) requestDispatch();
|
|
879
882
|
return { job };
|
|
880
883
|
}
|
|
881
884
|
|
|
@@ -889,6 +892,7 @@ export async function deleteJob(jobId, broadcast, { killSession } = {}) {
|
|
|
889
892
|
if (idx === -1) return { error: 'Job not found' };
|
|
890
893
|
const [removed] = jobs.splice(idx, 1);
|
|
891
894
|
persist(broadcast);
|
|
895
|
+
requestDispatch();
|
|
892
896
|
// After persist, so a file the OS will not let go of (open in a browser tab
|
|
893
897
|
// on Windows) cannot leave a card that is gone from memory but back on the
|
|
894
898
|
// next restart. The id is checked the same way a path is: it too is
|
|
@@ -1031,6 +1035,8 @@ export async function moveJob(jobId, state, broadcast, { killSession, findPr = f
|
|
|
1031
1035
|
// Persist and repaint before the kill so the card moves immediately; the kill
|
|
1032
1036
|
// then emits its own session-ended and orphan notifications.
|
|
1033
1037
|
persist(broadcast);
|
|
1038
|
+
// Back in To do, or a slot freed by leaving In progress.
|
|
1039
|
+
if (state === 'todo' || fromState === 'in-progress') requestDispatch();
|
|
1034
1040
|
if (retiringSessionId && killSession) {
|
|
1035
1041
|
const session = sessions.get(retiringSessionId);
|
|
1036
1042
|
if (session && !session.exited) {
|
|
@@ -1103,6 +1109,8 @@ export async function releasePushedOrphans(broadcast) {
|
|
|
1103
1109
|
|
|
1104
1110
|
export function updateSettings(fields, broadcast) {
|
|
1105
1111
|
const settings = boardSettings();
|
|
1112
|
+
// Starting the board, or raising its cap, makes room right away.
|
|
1113
|
+
const before = { running: settings.running, maxPerRepo: settings.maxPerRepo };
|
|
1106
1114
|
if (typeof fields.running === 'boolean') settings.running = fields.running;
|
|
1107
1115
|
if (Number.isFinite(fields.maxPerRepo)) settings.maxPerRepo = Math.max(1, Math.min(10, Math.floor(fields.maxPerRepo)));
|
|
1108
1116
|
if (Number.isFinite(fields.intervalMs)) settings.intervalMs = Math.max(30_000, Math.min(60 * 60_000, Math.floor(fields.intervalMs)));
|
|
@@ -1114,6 +1122,7 @@ export function updateSettings(fields, broadcast) {
|
|
|
1114
1122
|
settings.permissionModeChosen = true;
|
|
1115
1123
|
}
|
|
1116
1124
|
persist(broadcast);
|
|
1125
|
+
if (settings.running && (!before.running || settings.maxPerRepo > before.maxPerRepo)) requestDispatch();
|
|
1117
1126
|
return { settings };
|
|
1118
1127
|
}
|
|
1119
1128
|
|
|
@@ -2130,6 +2139,31 @@ export async function runScan(createSession, broadcast, { onSessionCreated, kill
|
|
|
2130
2139
|
}
|
|
2131
2140
|
}
|
|
2132
2141
|
|
|
2142
|
+
// --- Dispatch on events ---
|
|
2143
|
+
|
|
2144
|
+
// A card posted or requeued, or a slot freed, asks for a dispatch pass here
|
|
2145
|
+
// instead of waiting out the scan interval (5 minutes by default). Events
|
|
2146
|
+
// within DISPATCH_DEBOUNCE_MS coalesce into one pass. The pass is dispatchOnce
|
|
2147
|
+
// alone, behind the scan's flag: the PR and merge sweeps ask GitHub about
|
|
2148
|
+
// every card and have nothing to do with a card just posted, so they stay on
|
|
2149
|
+
// the interval. A pass that finds a scan running asks again, since the scan
|
|
2150
|
+
// may have picked its candidates before the event.
|
|
2151
|
+
export const DISPATCH_DEBOUNCE_MS = 2000;
|
|
2152
|
+
let dispatchPass = null;
|
|
2153
|
+
let kickTimer = null;
|
|
2154
|
+
|
|
2155
|
+
export function requestDispatch() {
|
|
2156
|
+
if (!dispatchPass || kickTimer) return;
|
|
2157
|
+
kickTimer = setTimeout(async () => {
|
|
2158
|
+
kickTimer = null;
|
|
2159
|
+
try { await dispatchPass(); } catch (err) {
|
|
2160
|
+
console.error('Job dispatch pass failed:', err.message);
|
|
2161
|
+
}
|
|
2162
|
+
}, DISPATCH_DEBOUNCE_MS);
|
|
2163
|
+
// Never the thing keeping a process alive: the interval loop is the board.
|
|
2164
|
+
kickTimer.unref?.();
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2133
2167
|
// --- Loop ---
|
|
2134
2168
|
|
|
2135
2169
|
let dispatchTimer = null;
|
|
@@ -2165,12 +2199,25 @@ export function startDispatcher(createSession, broadcast, { onSessionCreated, ki
|
|
|
2165
2199
|
if (generation === loopGeneration) ciTimer = setTimeout(ciTick, CI_POLL_MS);
|
|
2166
2200
|
};
|
|
2167
2201
|
ciTimer = setTimeout(ciTick, CI_POLL_MS);
|
|
2202
|
+
dispatchPass = async () => {
|
|
2203
|
+
if (!boardSettings().running) return;
|
|
2204
|
+
if (scanInFlight) { requestDispatch(); return; }
|
|
2205
|
+
scanInFlight = true;
|
|
2206
|
+
try {
|
|
2207
|
+
await dispatchOnce(createSession, broadcast, { onSessionCreated, killSession });
|
|
2208
|
+
} finally {
|
|
2209
|
+
scanInFlight = false;
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2168
2212
|
}
|
|
2169
2213
|
|
|
2170
2214
|
export function stopDispatcher() {
|
|
2171
2215
|
loopGeneration++;
|
|
2172
2216
|
clearTimeout(dispatchTimer);
|
|
2173
2217
|
clearTimeout(ciTimer);
|
|
2218
|
+
clearTimeout(kickTimer);
|
|
2174
2219
|
dispatchTimer = null;
|
|
2175
2220
|
ciTimer = null;
|
|
2221
|
+
kickTimer = null;
|
|
2222
|
+
dispatchPass = null;
|
|
2176
2223
|
}
|
package/server/owner.js
CHANGED
|
@@ -16,7 +16,7 @@ import { CONFIG_DIR } from './state.js';
|
|
|
16
16
|
import { liveBillion } from './billion.js';
|
|
17
17
|
import { sendText } from './messages.js';
|
|
18
18
|
import {
|
|
19
|
-
chooseMode, speechUnavailable, synthesize, whisperSetup, transcribe, MAX_NOTE_SECONDS, MAX_NOTE_BYTES,
|
|
19
|
+
chooseMode, voiceSetting, speechUnavailable, synthesize, sayVoice, whisperSetup, transcribe, MAX_NOTE_SECONDS, MAX_NOTE_BYTES,
|
|
20
20
|
} from './voice.js';
|
|
21
21
|
|
|
22
22
|
export const MAX_NOTIFY_CHARS = 3000; // Telegram's own limit is 4096
|
|
@@ -98,7 +98,7 @@ function saveOwnerMode(mode) {
|
|
|
98
98
|
export async function sendVoice(text, { env = process.env } = {}) {
|
|
99
99
|
const { chatId } = telegramSettings(env);
|
|
100
100
|
try {
|
|
101
|
-
const ogg = await synthesize(text);
|
|
101
|
+
const ogg = await synthesize(text, env);
|
|
102
102
|
const form = new FormData();
|
|
103
103
|
form.append('chat_id', chatId);
|
|
104
104
|
form.append('caption', text); // under Telegram's 1024: voice is for texts of 900 or fewer
|
|
@@ -296,6 +296,9 @@ export function startTelegram({ broadcast, env = process.env } = {}) {
|
|
|
296
296
|
if (!token || stopper) return false;
|
|
297
297
|
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)');
|
|
298
298
|
else console.log(' Telegram: on');
|
|
299
|
+
if (voiceSetting(env) !== 'never' && !speechUnavailable(env)) {
|
|
300
|
+
sayVoice(env).then(v => console.log(` Telegram: speaking with ${v ? `the ${v} voice` : "say's default voice"}`));
|
|
301
|
+
}
|
|
299
302
|
const controller = new AbortController();
|
|
300
303
|
stopper = controller;
|
|
301
304
|
(async () => {
|
package/server/pty.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { spawn as spawnPty } from 'node-pty';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { basename } from 'path';
|
|
6
|
+
import { writeSync } from 'fs';
|
|
6
7
|
import { stripAnsiComplete, detectState, createRingBuffer, parseCommand, isRealOutput, trackSyncFrames, ptyEnv } from '../lib/helpers.js';
|
|
7
8
|
// Re-exported so the handler's tests reach the parser through the module they drive.
|
|
8
9
|
export { trackSyncFrames } from '../lib/helpers.js';
|
|
@@ -10,7 +11,7 @@ import { resolveExecutable, isUsableCwd, commandExists, missingCommandMessage }
|
|
|
10
11
|
import { RING_BUFFER_MAX } from './state.js';
|
|
11
12
|
import { mintAgentToken, authEnabled } from './auth.js';
|
|
12
13
|
import { writeMcpConfig, removeMcpConfig, withMcpConfig, takesMcpConfig, withApprovalHook } from './agent-mcp.js';
|
|
13
|
-
import { broadcastJobs } from './jobs.js';
|
|
14
|
+
import { broadcastJobs, requestDispatch } from './jobs.js';
|
|
14
15
|
import { flushMessages, dropMessages } from './messages.js';
|
|
15
16
|
import { sessionAgentFromCommand, permissionFlagsFromCommand } from '../lib/jobs.js';
|
|
16
17
|
import { trustDialogKey } from './billion.js';
|
|
@@ -36,8 +37,10 @@ function installAsyncSpawnGuard() {
|
|
|
36
37
|
const match = ASYNC_SPAWN_FAILURE_RE.exec(err?.message || '');
|
|
37
38
|
if (!match) {
|
|
38
39
|
// Not ours. Reproduce Node's default uncaughtException behaviour rather
|
|
39
|
-
// than silently swallowing an unrelated bug.
|
|
40
|
-
console.error
|
|
40
|
+
// than silently swallowing an unrelated bug. Written synchronously:
|
|
41
|
+
// console.error to a pipe is async on Windows, and process.exit dropped
|
|
42
|
+
// it, leaving a crash with no error to read.
|
|
43
|
+
try { writeSync(2, `${err?.stack || err}\n`); } catch { /* exiting anyway */ }
|
|
41
44
|
process.exit(1);
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -158,6 +161,8 @@ export function setupPtyHandlers(session, sessionId, broadcast) {
|
|
|
158
161
|
dropMessages(sessionId);
|
|
159
162
|
if (session.isBillion) dropApprovals();
|
|
160
163
|
updateState(session, broadcast);
|
|
164
|
+
// A board worker gone frees its repo's slot.
|
|
165
|
+
if (session.jobId) requestDispatch();
|
|
161
166
|
// What it is as it ends, which a relink or a board retirement may have
|
|
162
167
|
// changed since session-created: the client's finished-worker path reads it.
|
|
163
168
|
broadcast({ type: 'session-ended', sessionId, reason: `Process exited with code ${exitCode}`,
|
package/server/voice.js
CHANGED
|
@@ -79,12 +79,54 @@ export function speechUnavailable(env = process.env, platform = process.platform
|
|
|
79
79
|
return null;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// `say -v '?'` lines, e.g. "Ava (Premium) en_US # Hello! My name is Ava."
|
|
83
|
+
export function parseVoices(out) {
|
|
84
|
+
return out.split('\n').map(l => l.match(/^(.+?)\s+([a-z]{2,3}[_-]\w+)\s+#/)).filter(Boolean)
|
|
85
|
+
.map(([, name, locale]) => ({ name, locale: locale.replace('-', '_') }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The first Premium voice, then Enhanced (however macOS words the name), in
|
|
89
|
+
// these locales in order.
|
|
90
|
+
function best(voices, locales) {
|
|
91
|
+
for (const tier of ['Premium', 'Enhanced']) {
|
|
92
|
+
for (const locale of locales) {
|
|
93
|
+
const v = voices.find(v => (!locale || v.locale === locale) && v.name.includes(tier));
|
|
94
|
+
if (v) return v;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// SAY_VOICE if it is installed, else the best English voice installed:
|
|
101
|
+
// Premium, then Enhanced, en_US before en_GB. null keeps say's own default.
|
|
102
|
+
export function pickVoice(voices, env = process.env) {
|
|
103
|
+
const wanted = (env.SAY_VOICE || '').trim();
|
|
104
|
+
if (wanted) {
|
|
105
|
+
// "Ava" also matches "Ava (Premium)" or "Samantha" "Samantha (English (US))",
|
|
106
|
+
// as `say -v` itself does, the best of them first.
|
|
107
|
+
const w = wanted.toLowerCase();
|
|
108
|
+
const named = voices.filter(v => v.name.toLowerCase().startsWith(`${w} (`));
|
|
109
|
+
const found = voices.find(v => v.name.toLowerCase() === w) || best(named, [null]) || named[0];
|
|
110
|
+
if (found) return found.name;
|
|
111
|
+
console.log(` Telegram: SAY_VOICE "${wanted}" is not installed (say -v '?' lists what is); using the best installed voice`);
|
|
112
|
+
}
|
|
113
|
+
return best(voices, ['en_US', 'en_GB'])?.name ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let voicePick;
|
|
117
|
+
// The voice Billion speaks with, chosen once for the process's lifetime.
|
|
118
|
+
export function sayVoice(env = process.env) {
|
|
119
|
+
voicePick ??= run('say', ['-v', '?']).catch(() => '').then(out => pickVoice(parseVoices(out), env));
|
|
120
|
+
return voicePick;
|
|
121
|
+
}
|
|
122
|
+
|
|
82
123
|
// text → OGG/Opus bytes. URLs are said as "link"; the caption carries them.
|
|
83
|
-
export function synthesize(text) {
|
|
124
|
+
export function synthesize(text, env = process.env) {
|
|
84
125
|
return inTempDir(async dir => {
|
|
85
126
|
const txt = join(dir, 'say.txt'), aiff = join(dir, 'say.aiff'), ogg = join(dir, 'say.ogg');
|
|
86
127
|
await writeFile(txt, text.replace(URL_RE, 'link'));
|
|
87
|
-
|
|
128
|
+
const voice = await sayVoice(env);
|
|
129
|
+
await run('say', [...(voice ? ['-v', voice] : []), '-o', aiff, '-f', txt]);
|
|
88
130
|
await run('ffmpeg', ['-y', '-loglevel', 'error', '-protocol_whitelist', 'file', '-i', aiff, '-c:a', 'libopus', '-b:a', '32k', ogg]);
|
|
89
131
|
return readFile(ogg);
|
|
90
132
|
});
|