@bill10/agent-007 0.12.2000 → 0.12.2002

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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.12.2.0
1
+ 0.12.2.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bill10/agent-007",
3
- "version": "0.12.2000",
3
+ "version": "0.12.2002",
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/models.js CHANGED
@@ -1,16 +1,16 @@
1
1
  // Which models a card may name, per CLI, discovered on this machine so nobody
2
- // keeps a list. Nothing here goes to the network: Claude Code's aliases are
3
- // fixed words its --model resolves to the newest model itself, and Codex
4
- // already caches the account's model list on disk.
2
+ // keeps a list. Claude Code's aliases are fixed words its --model resolves to
3
+ // the newest model itself; Codex is asked for its catalog.
5
4
  //
6
5
  // A CLI that is not on the PATH the board spawns with offers nothing — a card
7
6
  // could not run there anyway — and so does anything that fails to read. An
8
7
  // empty list leaves only the CLI's own default on offer.
9
8
 
9
+ import { execFile } from 'child_process';
10
10
  import { readFileSync } from 'fs';
11
11
  import { homedir } from 'os';
12
12
  import { join } from 'path';
13
- import { commandExists } from './command-path.js';
13
+ import { commandExists, resolveExecutable } from './command-path.js';
14
14
  import { isSafeModelName } from '../lib/jobs.js';
15
15
 
16
16
  // `claude --model` takes these as "the latest model of that family"
@@ -21,30 +21,51 @@ export const CLAUDE_ALIASES = ['fable', 'opus', 'sonnet', 'haiku'];
21
21
 
22
22
  const codexHome = (env) => env.CODEX_HOME || join(homedir(), '.codex');
23
23
 
24
- // Codex (0.156) keeps the models its account can use in
25
- // $CODEX_HOME/models_cache.json: { fetched_at, client_version, models: [{ slug,
26
- // visibility, priority, ... }] }. Only `visibility: "list"` entries are kept:
27
- // that is Codex's own filter for its /model picker, and it is what hides the
28
- // internal ones (codex-auto-review, the reviewer's model; gpt-reserve) that
29
- // are not for a coding session. Picker order is `priority`, lowest first. A
30
- // slug must also pass isSafeModelName, since it ends up on a command line.
31
- // The CLI has no plain list-models subcommand (its app-server's model/list is
32
- // a JSON-RPC session, not a one-shot), so this reads the cache its picker is
33
- // built from — read-only; nothing here ever writes under CODEX_HOME.
34
- export function parseCodexModels(text) {
24
+ // Codex's catalog, from `codex debug models` (0.157: "Render the raw model
25
+ // catalog as JSON") or, failing that, the cache its picker is built from,
26
+ // $CODEX_HOME/models_cache.json. Both are { models: [{ slug, visibility,
27
+ // priority, ... }] }. Only `visibility: "list"` entries are kept: that is
28
+ // Codex's own filter for its /model picker, and it is what hides the internal
29
+ // ones (codex-auto-review, the reviewer's model; gpt-reserve) that are not for
30
+ // a coding session. Picker order is `priority`, lowest first. A slug must also
31
+ // pass isSafeModelName, since it ends up on a command line. Null when the text
32
+ // is not a catalog. Read-only; nothing here ever writes under CODEX_HOME.
33
+ function listedCodexModels(text) {
35
34
  let models;
36
- try { models = JSON.parse(text)?.models; } catch { return []; }
37
- if (!Array.isArray(models)) return [];
35
+ try { models = JSON.parse(text)?.models; } catch { return null; }
36
+ if (!Array.isArray(models)) return null;
38
37
  return models
39
38
  .filter(m => m && m.visibility === 'list' && isSafeModelName(m.slug))
40
39
  .sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0))
41
40
  .map(m => m.slug);
42
41
  }
43
42
 
44
- export function discoverModels({ env = process.env, exists = (f) => commandExists(f, env), read = (p) => readFileSync(p, 'utf8') } = {}) {
43
+ export const parseCodexModels = (text) => listedCodexModels(text) ?? [];
44
+
45
+ // The same codex, found the same way, the board launches workers with. No
46
+ // shell. On Windows the npm install is codex.cmd, which execFile will not run
47
+ // without one, so there this fails and the cache is read instead.
48
+ export function runCodexModels(env) {
49
+ return new Promise((resolve, reject) => {
50
+ execFile(resolveExecutable('codex', env) ?? 'codex', ['debug', 'models'],
51
+ { env, timeout: 10_000, maxBuffer: 32 * 1024 * 1024, windowsHide: true },
52
+ (err, stdout) => (err ? reject(err) : resolve(stdout)));
53
+ });
54
+ }
55
+
56
+ export async function discoverModels({
57
+ env = process.env, exists = (f) => commandExists(f, env), read = (p) => readFileSync(p, 'utf8'),
58
+ run = runCodexModels, log = console.log,
59
+ } = {}) {
45
60
  let codex = [];
46
61
  if (exists('codex')) {
47
- try { codex = parseCodexModels(read(join(codexHome(env), 'models_cache.json'))); } catch { /* no cache yet */ }
62
+ let source = '`codex debug models`';
63
+ codex = await run(env).then(listedCodexModels, () => null);
64
+ if (!codex) {
65
+ source = 'models_cache.json';
66
+ try { codex = parseCodexModels(read(join(codexHome(env), 'models_cache.json'))); } catch { codex = []; source = 'nowhere (no models_cache.json either)'; }
67
+ }
68
+ log(` Models: Codex's from ${source}`);
48
69
  }
49
70
  return { claude: exists('claude') ? [...CLAUDE_ALIASES] : [], codex };
50
71
  }
@@ -55,19 +76,22 @@ const STALE_MS = 10 * 60 * 1000;
55
76
  let cached = { claude: [], codex: [] };
56
77
  let cachedAt = 0;
57
78
 
58
- export function refreshModels(opts) {
59
- cached = discoverModels(opts);
79
+ // cachedAt is stamped before the await so a second ask while Codex is still
80
+ // answering does not start another run. It never rejects: callers fire and
81
+ // forget, and a failed look keeps the last answer.
82
+ export async function refreshModels(opts) {
60
83
  cachedAt = Date.now();
84
+ try { cached = await discoverModels(opts); } catch (err) { console.error(' Models: discovery failed:', err); }
61
85
  return cached;
62
86
  }
63
87
 
64
88
  export function availableModels() { return cached; }
65
89
 
66
90
  // Whether a refresh changed anything, so the caller only repaints boards when it did.
67
- export function refreshIfStale(now = Date.now()) {
91
+ export async function refreshIfStale(now = Date.now()) {
68
92
  if (now - cachedAt < STALE_MS) return false;
69
93
  const before = JSON.stringify(cached);
70
- return JSON.stringify(refreshModels()) !== before;
94
+ return JSON.stringify(await refreshModels()) !== before;
71
95
  }
72
96
 
73
97
  export function startModelRefresh() {
package/server/owner.js CHANGED
@@ -59,11 +59,17 @@ async function call(method, params, { env = process.env, signal } = {}) {
59
59
  ...(form ? { body: params } : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params) }),
60
60
  signal,
61
61
  });
62
- body = await res.json().catch(() => ({ ok: false, description: `HTTP ${res.status}` }));
62
+ body = await res.json().catch(() => ({ ok: false, error_code: res.status, description: `HTTP ${res.status}` }));
63
63
  } catch (err) {
64
- throw new Error(redact(err.cause?.message || err.message, env));
64
+ // reason: a short label for the poll loop's offline line.
65
+ const reason = /ENOTFOUND|EAI_AGAIN/.test(err.cause?.code) ? 'DNS'
66
+ : err.name === 'TimeoutError' || /timeout/i.test(err.message) ? 'timeout' : 'no network';
67
+ throw Object.assign(new Error(redact(err.cause?.message || err.message, env)), { reason });
68
+ }
69
+ if (!body?.ok) {
70
+ const status = body?.error_code;
71
+ throw Object.assign(new Error(redact(body?.description || 'Telegram said no', env)), { status, reason: status ? `HTTP ${status}` : 'Telegram said no' });
65
72
  }
66
- if (!body?.ok) throw new Error(redact(body?.description || 'Telegram said no', env));
67
73
  return body.result;
68
74
  }
69
75
 
@@ -414,6 +420,16 @@ export async function pollOnce(offset, { broadcast, env = process.env, signal }
414
420
 
415
421
  let stopper = null;
416
422
 
423
+ // Failures that retrying will not fix, and what the owner should do about them.
424
+ const POLL_HINTS = {
425
+ 401: 'the bot token is wrong or revoked',
426
+ 409: 'another process is polling this bot',
427
+ };
428
+ const OFFLINE_AFTER_MS = 60 * 1000;
429
+ const OFFLINE_AFTER_FAILURES = 3;
430
+
431
+ const since = ms => ms < 120e3 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60e3)}m`;
432
+
417
433
  const pause = (ms, signal) => new Promise(resolve => {
418
434
  const timer = setTimeout(resolve, ms);
419
435
  signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
@@ -432,13 +448,27 @@ export function startTelegram({ broadcast, env = process.env } = {}) {
432
448
  (async () => {
433
449
  let offset = 0;
434
450
  let backoff = 1000;
451
+ // Sleep, wake and network changes fail a poll or two; log only going
452
+ // offline and coming back, not every retry.
453
+ let failures = 0, failingSince = 0, offline = false;
435
454
  while (!controller.signal.aborted) {
436
455
  try {
437
456
  offset = await pollOnce(offset, { broadcast, env, signal: controller.signal });
457
+ if (offline) console.log(`Telegram: back online after ${since(Date.now() - failingSince)}`);
458
+ failures = 0;
459
+ offline = false;
438
460
  backoff = 1000;
439
461
  } catch (err) {
440
462
  if (controller.signal.aborted) break;
441
- console.error(`Telegram: getUpdates failed (${err.message}); retrying in ${backoff / 1000}s`);
463
+ if (!failures++) failingSince = Date.now();
464
+ const hint = POLL_HINTS[err.status];
465
+ // A hint logs even mid-outage: a token revoked while offline still needs saying.
466
+ if (hint ? offline !== hint : !offline && (failures >= OFFLINE_AFTER_FAILURES || Date.now() - failingSince > OFFLINE_AFTER_MS)) {
467
+ offline = hint || true;
468
+ console.error(hint
469
+ ? `Telegram: getUpdates failed (${err.message}): ${hint}; retrying quietly`
470
+ : `Telegram: offline (${err.reason || 'no network'}), retrying quietly`);
471
+ }
442
472
  await pause(backoff, controller.signal);
443
473
  backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
444
474
  }
package/server/ws.js CHANGED
@@ -635,7 +635,7 @@ export function setupWebSocket(wss, { createSession, killSession, startBillion }
635
635
  // The card form opening: look again if the list is over 10 minutes
636
636
  // old, so a CLI installed since shows up without a restart.
637
637
  case 'models-refresh': {
638
- if (refreshIfStale()) broadcastJobs(broadcast);
638
+ refreshIfStale().then(changed => { if (changed) broadcastJobs(broadcast); }).catch(err => console.error('models-refresh:', err));
639
639
  break;
640
640
  }
641
641
  case 'job-pause': {