@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 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.1
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.1001",
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(err);
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
- await run('say', ['-o', aiff, '-f', txt]);
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
  });