getbotons 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Patterns from the CLIs' own messages (claude 2.1.316, codex-cli 0.166.0). Transient ones are narrow on
|
|
2
|
+
// purpose: a permanent error taken for a network blip runs the work again.
|
|
3
|
+
import { isValidTimezone } from '../../../shared/util.ts';
|
|
4
|
+
import { zonedToUtc } from '../schedule.ts';
|
|
5
|
+
|
|
6
|
+
export type FailureKind = 'usage_limit' | 'auth' | 'transient' | 'other';
|
|
7
|
+
|
|
8
|
+
/** When a usage limit gives no reset time. */
|
|
9
|
+
export const DEFAULT_COOLDOWN_MS = 30 * 60_000;
|
|
10
|
+
/** A reset in the past (a skewed clock) would otherwise re-run the turn at once, over and over. */
|
|
11
|
+
export const MIN_COOLDOWN_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
const USAGE_LIMIT = [
|
|
14
|
+
/usage limit reached/i,
|
|
15
|
+
/\bhit your (?:[\w'’-]+ ){0,3}limit\b/i,
|
|
16
|
+
/\breached your (?:[\w'’-]+ ){0,2}limit\b/i,
|
|
17
|
+
/\bspend limit reached\b/i,
|
|
18
|
+
/\bout of usage credits\b/i,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const AUTH = [
|
|
22
|
+
/\bnot logged in\b/i,
|
|
23
|
+
/please run \/login/i,
|
|
24
|
+
/\binvalid api key\b/i,
|
|
25
|
+
/\boauth token (?:has )?(?:expired|revoked)\b/i,
|
|
26
|
+
/\bauthentication_error\b/i,
|
|
27
|
+
/\bauthentication failed\b/i,
|
|
28
|
+
/\b401\b[^\n]*unauthori[sz]ed/i,
|
|
29
|
+
/\bcodex login\b/i,
|
|
30
|
+
/\bno codex credentials\b/i,
|
|
31
|
+
/\bsign in again\b/i,
|
|
32
|
+
/\baccess token could not be refreshed\b/i,
|
|
33
|
+
/\brefresh token was rejected\b/i,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Not overloaded or 429 errors: both CLIs retry those themselves. */
|
|
37
|
+
const TRANSIENT = [
|
|
38
|
+
/\bconnection error\b/i,
|
|
39
|
+
/\brequest timed out\b/i,
|
|
40
|
+
/\bfetch failed\b/i,
|
|
41
|
+
/\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|EPIPE)\b/,
|
|
42
|
+
/\bsocket hang up\b/i,
|
|
43
|
+
/\bstream disconnected before completion\b/i,
|
|
44
|
+
/\berror sending request\b/i,
|
|
45
|
+
/\bconnection (?:failed|reset|refused)\b/i,
|
|
46
|
+
// Codex's reconnects used up on a network failure, not an HTTP status.
|
|
47
|
+
/^Reconnecting\.\.\. \d+\/\d+ \((?!unexpected status)/m,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** `transientText` is only the failure itself: stderr often carries harmless network noise. */
|
|
51
|
+
export function classifyFailure(said: string, transientText: string, opts: { now?: Date; localTz?: string } = {}): { kind: FailureKind; retryAt: Date | null } {
|
|
52
|
+
const now = opts.now ?? new Date();
|
|
53
|
+
if (USAGE_LIMIT.some((re) => re.test(said))) {
|
|
54
|
+
const localTz = opts.localTz ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
|
55
|
+
const reset = parseReset(said, now, localTz)?.getTime() ?? now.getTime() + DEFAULT_COOLDOWN_MS;
|
|
56
|
+
return { kind: 'usage_limit', retryAt: new Date(Math.max(reset, now.getTime() + MIN_COOLDOWN_MS)) };
|
|
57
|
+
}
|
|
58
|
+
if (AUTH.some((re) => re.test(said))) return { kind: 'auth', retryAt: null };
|
|
59
|
+
if (TRANSIENT.some((re) => re.test(transientText))) return { kind: 'transient', retryAt: null };
|
|
60
|
+
return { kind: 'other', retryAt: null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Weekly limits reset within 7 days: anything later is a misread. */
|
|
64
|
+
const MAX_AHEAD_MS = 8 * 86_400_000;
|
|
65
|
+
const CLOCK_SLACK_MS = 60_000;
|
|
66
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
67
|
+
|
|
68
|
+
/** Times without a zone are in `localTz` (the CLIs print local time). */
|
|
69
|
+
export function parseReset(text: string, now: Date, localTz: string): Date | null {
|
|
70
|
+
const ok = (d: Date | null) => (d && d.getTime() > now.getTime() - 5 * 60_000 && d.getTime() - now.getTime() <= MAX_AHEAD_MS ? d : null);
|
|
71
|
+
// "Claude AI usage limit reached|1762400000" (seconds since the epoch).
|
|
72
|
+
const epoch = /limit reached\|(\d{10,13})\b/i.exec(text);
|
|
73
|
+
if (epoch) {
|
|
74
|
+
const n = Number(epoch[1]);
|
|
75
|
+
const d = ok(new Date(n < 1e12 ? n * 1000 : n));
|
|
76
|
+
if (d) return d;
|
|
77
|
+
}
|
|
78
|
+
// "resets in 2h 30m", "try again in 4 days 3 hours".
|
|
79
|
+
const inDur = /\b(?:resets|try again) in ([^.\n·]+)/i.exec(text);
|
|
80
|
+
if (inDur) {
|
|
81
|
+
const ms = parseDurationWords(inDur[1]);
|
|
82
|
+
if (ms !== null) return ok(new Date(now.getTime() + ms));
|
|
83
|
+
}
|
|
84
|
+
// "resets 3pm (Europe/Berlin)", "resets Nov 7, 9am (…)", "resets 2026-08-08 00:00 UTC", "try again at Nov 5th, 2026 3:05 PM."
|
|
85
|
+
const at = /\b(?:resets|try again)(?: at| on)? ([^\n·]+)/i.exec(text);
|
|
86
|
+
if (at) {
|
|
87
|
+
const when = at[1].split(/\. |\.$| — | - /)[0];
|
|
88
|
+
const d = parseWhen(when, now, localTz);
|
|
89
|
+
if (d) return ok(d.getTime() <= now.getTime() ? new Date(now.getTime() + CLOCK_SLACK_MS) : d);
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseDurationWords(s: string): number | null {
|
|
95
|
+
const units: Record<string, number> = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1000 };
|
|
96
|
+
let total = 0;
|
|
97
|
+
let found = false;
|
|
98
|
+
for (const m of s.matchAll(/(\d+(?:\.\d+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b/gi)) {
|
|
99
|
+
found = true;
|
|
100
|
+
total += Number(m[1]) * units[m[2][0].toLowerCase()];
|
|
101
|
+
}
|
|
102
|
+
return found ? Math.round(total) : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseWhen(s: string, now: Date, localTz: string): Date | null {
|
|
106
|
+
const zone = /\(([A-Za-z]+(?:\/[A-Za-z0-9_+-]+)+)\)/.exec(s)?.[1] ?? (/\b(?:UTC|GMT)\b/.test(s) ? 'UTC' : localTz);
|
|
107
|
+
const tz = isValidTimezone(zone) ? zone : localTz;
|
|
108
|
+
const iso = /(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})/.exec(s);
|
|
109
|
+
if (iso) return zonedToUtc(+iso[1], +iso[2], +iso[3], +iso[4], +iso[5], tz);
|
|
110
|
+
|
|
111
|
+
const time = /(?:^|[\s,])(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\.?(?![a-z])|(?:^|[\s,])(\d{1,2}):(\d{2})(?![\d:])/i.exec(s);
|
|
112
|
+
if (!time) return null;
|
|
113
|
+
let h = Number(time[1] ?? time[4]);
|
|
114
|
+
const min = Number(time[2] ?? time[5] ?? 0);
|
|
115
|
+
const ap = time[3]?.toLowerCase();
|
|
116
|
+
if (ap === 'p' && h < 12) h += 12;
|
|
117
|
+
if (ap === 'a' && h === 12) h = 0;
|
|
118
|
+
if (h > 23 || min > 59) return null;
|
|
119
|
+
|
|
120
|
+
const today = ymdIn(now, tz);
|
|
121
|
+
const md = /\b([A-Za-z]{3})[a-z]*\.? (\d{1,2})(?:st|nd|rd|th)?\b(?:,? (\d{4}))?/.exec(s);
|
|
122
|
+
const month = md ? MONTHS.indexOf(md[1].toLowerCase()) : -1;
|
|
123
|
+
if (md && month >= 0) {
|
|
124
|
+
let year = md[3] ? Number(md[3]) : today.y;
|
|
125
|
+
let d = zonedToUtc(year, month + 1, Number(md[2]), h, min, tz);
|
|
126
|
+
// "Jan 2" seen on Dec 30 means next year.
|
|
127
|
+
if (!md[3] && d.getTime() < now.getTime() - 86_400_000) d = zonedToUtc(++year, month + 1, Number(md[2]), h, min, tz);
|
|
128
|
+
return new Date(d.getTime() + CLOCK_SLACK_MS);
|
|
129
|
+
}
|
|
130
|
+
// A time of day only: today, or tomorrow if that has passed (a minute ago still counts as now).
|
|
131
|
+
let d = zonedToUtc(today.y, today.m, today.d, h, min, tz);
|
|
132
|
+
if (d.getTime() < now.getTime() - 5 * 60_000) {
|
|
133
|
+
const t = ymdIn(new Date(now.getTime() + 86_400_000), tz);
|
|
134
|
+
d = zonedToUtc(t.y, t.m, t.d, h, min, tz);
|
|
135
|
+
}
|
|
136
|
+
return new Date(d.getTime() + CLOCK_SLACK_MS);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ymdIn(at: Date, tz: string): { y: number; m: number; d: number } {
|
|
140
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(at);
|
|
141
|
+
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
|
|
142
|
+
return { y: get('year'), m: get('month'), d: get('day') };
|
|
143
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { type ModelEfforts, providerEffort } from '../../../shared/effort.ts';
|
|
3
|
+
import type { ProviderEffortInfo, ProviderInfo } from '../../../shared/types.ts';
|
|
4
|
+
import { nowIso, truncate } from '../util.ts';
|
|
5
|
+
import { ClaudeCliProvider } from './claude-cli.ts';
|
|
6
|
+
import { CodexCliProvider } from './codex-cli.ts';
|
|
7
|
+
import { MockProvider } from './mock.ts';
|
|
8
|
+
import { brainReadiness } from './readiness.ts';
|
|
9
|
+
import type { Provider } from './types.ts';
|
|
10
|
+
|
|
11
|
+
/** Rechecks the owner can ask for, per provider, in OWNER_RECHECK_WINDOW_MS; past that they get the last check. */
|
|
12
|
+
export const OWNER_RECHECKS = 6;
|
|
13
|
+
export const OWNER_RECHECK_WINDOW_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
export class ProviderRegistry {
|
|
16
|
+
#providers = new Map<string, Provider>();
|
|
17
|
+
/** Replacing a CLI replaces its checker, so its counters need a fresh identity. */
|
|
18
|
+
#incarnations = new Map<string, string>();
|
|
19
|
+
/** How each provider's real turns went, since the server started. */
|
|
20
|
+
#turns = new Map<string, Pick<ProviderInfo, 'lastSuccessAt' | 'lastError'>>();
|
|
21
|
+
/** When the owner's rechecks of each provider started, within the window. */
|
|
22
|
+
#rechecks = new Map<string, number[]>();
|
|
23
|
+
/** Until when each provider's turns are held by a usage limit (the engine's). */
|
|
24
|
+
#usageLimitUntil: (id: string) => string | null = () => null;
|
|
25
|
+
/** This run of the server, for its answers' readiness: checks are counted per run, and runs aren't ordered by clock. */
|
|
26
|
+
readonly boot = randomUUID();
|
|
27
|
+
|
|
28
|
+
/** `managed`: which CLIs are Botons' own copies (managed-clis.ts). */
|
|
29
|
+
constructor(opts: { enableMock: boolean; claudeBin?: string; codexBin?: string; managed?: { claude?: boolean; codex?: boolean } }) {
|
|
30
|
+
this.register(new ClaudeCliProvider(opts.claudeBin ?? process.env.BOTONS_CLAUDE_BIN ?? 'claude', opts.managed?.claude));
|
|
31
|
+
this.register(new CodexCliProvider(opts.codexBin ?? process.env.BOTONS_CODEX_BIN ?? 'codex', undefined, opts.managed?.codex));
|
|
32
|
+
if (opts.enableMock) this.register(new MockProvider());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
register(p: Provider) {
|
|
36
|
+
if (this.#providers.has(p.id)) { this.#incarnations.set(p.id, randomUUID()); this.#rechecks.delete(p.id); }
|
|
37
|
+
this.#providers.set(p.id, p);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get(id: string): Provider | undefined {
|
|
41
|
+
return this.#providers.get(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ids(): Set<string> {
|
|
45
|
+
return new Set(this.#providers.keys());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What `provider`'s own model list says `model` takes, or null when nothing is known about it. */
|
|
49
|
+
modelEfforts(provider: string, model: string): ModelEfforts | null {
|
|
50
|
+
return this.#providers.get(provider)?.modelEfforts?.(model.trim()) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Where the usage limits come from, so each provider's readiness shows its own. */
|
|
54
|
+
setUsageLimits(until: (id: string) => string | null): void {
|
|
55
|
+
this.#usageLimitUntil = until;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async infos(opts: { recheck?: boolean } = {}): Promise<ProviderInfo[]> {
|
|
59
|
+
const providers = [...this.#providers.values()];
|
|
60
|
+
if (opts.recheck) for (const p of providers) p.recheck?.();
|
|
61
|
+
return Promise.all(
|
|
62
|
+
providers.map(async (p) => {
|
|
63
|
+
const boot = this.#incarnations.get(p.id) ?? this.boot;
|
|
64
|
+
const turns = this.#turns.get(p.id);
|
|
65
|
+
const { check, ...info } = await p.info();
|
|
66
|
+
const readiness = brainReadiness({ ...info, check }, this.#usageLimitUntil(p.id), Date.now(), boot);
|
|
67
|
+
return { ...info, effort: info.effort ?? effortInfo(p.id, p.label), lastSuccessAt: turns?.lastSuccessAt ?? null, lastError: turns?.lastError ?? null, readiness };
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The providers as infos() would show them, from the checks already done: runs no check, whatever their age. A
|
|
74
|
+
* provider never checked is 'unknown' ('not-checked'), one checked too long ago 'unknown' ('outdated').
|
|
75
|
+
*/
|
|
76
|
+
snapshots(now = Date.now()): ProviderInfo[] {
|
|
77
|
+
return [...this.#providers.values()].map((p) => {
|
|
78
|
+
const turns = this.#turns.get(p.id);
|
|
79
|
+
const status = p.snapshot ? p.snapshot() : { id: p.id, label: p.label, kind: 'cli' as const, available: false, reason: 'Not checked yet since the server started.', installed: false, version: null, loggedIn: null, checkedAt: null, models: [], defaultModel: '' };
|
|
80
|
+
const { check, ...info } = status;
|
|
81
|
+
const readiness = brainReadiness({ ...info, check }, this.#usageLimitUntil(p.id), now, this.#incarnations.get(p.id) ?? this.boot);
|
|
82
|
+
return { ...info, effort: info.effort ?? effortInfo(p.id, p.label), lastSuccessAt: turns?.lastSuccessAt ?? null, lastError: turns?.lastError ?? null, readiness };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The owner's "check again": each provider's checks run anew, unless it was asked OWNER_RECHECKS times in the last
|
|
88
|
+
* OWNER_RECHECK_WINDOW_MS, so a held-down button can't keep the CLIs busy. A check already running is waited for and
|
|
89
|
+
* a new one runs after it (CliChecker): never two at once. Returns the providers whose checks run anew.
|
|
90
|
+
*/
|
|
91
|
+
ownerRecheck(now = Date.now()): string[] {
|
|
92
|
+
const rechecked: string[] = [];
|
|
93
|
+
for (const p of this.#providers.values()) {
|
|
94
|
+
if (!p.recheck) continue;
|
|
95
|
+
const recent = (this.#rechecks.get(p.id) ?? []).filter((t) => now - t < OWNER_RECHECK_WINDOW_MS);
|
|
96
|
+
if (recent.length < OWNER_RECHECKS) {
|
|
97
|
+
recent.push(now);
|
|
98
|
+
p.recheck();
|
|
99
|
+
rechecked.push(p.id);
|
|
100
|
+
}
|
|
101
|
+
this.#rechecks.set(p.id, recent);
|
|
102
|
+
}
|
|
103
|
+
return rechecked;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** After a failure the checks run again at once (in the background), so a signed-out CLI gets logged. */
|
|
107
|
+
recordTurn(id: string, outcome: { ok: true } | { ok: false; error: string }): void {
|
|
108
|
+
const rec = this.#turns.get(id) ?? { lastSuccessAt: null, lastError: null };
|
|
109
|
+
const at = nowIso();
|
|
110
|
+
if (outcome.ok) rec.lastSuccessAt = at;
|
|
111
|
+
else rec.lastError = { at, message: truncate(outcome.error, 500) };
|
|
112
|
+
this.#turns.set(id, rec);
|
|
113
|
+
const p = this.#providers.get(id);
|
|
114
|
+
if (!outcome.ok && p?.recheck) {
|
|
115
|
+
p.recheck();
|
|
116
|
+
p.info().catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async defaultProvider(): Promise<string> {
|
|
121
|
+
const infos = await this.infos();
|
|
122
|
+
return (infos.find((i) => i.available && i.id !== 'mock') ?? infos[0]).id;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What the owner's effort control offers for a provider: its levels, or why it has none. */
|
|
127
|
+
export function effortInfo(id: string, label: string): ProviderEffortInfo {
|
|
128
|
+
const p = providerEffort(id);
|
|
129
|
+
if (!p) return { levels: [], reason: `${label} has no effort setting Botons can pass on, so it runs at its own default.`, via: null, defaultText: 'the provider’s default' };
|
|
130
|
+
return { levels: p.levels.map((l) => ({ id: l.id, label: l.label })), reason: null, via: p.via, defaultText: p.defaultText };
|
|
131
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { VERSION } from '../../../shared/app-version.mjs';
|
|
2
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
3
|
+
import { cleanResult, cleanTools, type McpTool, type ToolResult } from '../mcp-client.ts';
|
|
4
|
+
import { redact } from '../secrets.ts';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Just enough of an MCP client over stdio (newline-delimited JSON-RPC) for an API brain's library and browser
|
|
8
|
+
* connectors: initialize, list tools, call one. The server runs as the CLIs run their MCP servers: as the server's user,
|
|
9
|
+
* in its own process group, with only the environment it is given; it is killed when the turn ends. A library
|
|
10
|
+
* connector comes here already wrapped in the Workspace sandbox (connector-launch.ts).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PROTOCOL = '2025-06-18';
|
|
14
|
+
const MAX_LINE = 4 * 1024 * 1024;
|
|
15
|
+
const MAX_TOOL_PAGES = 20;
|
|
16
|
+
|
|
17
|
+
export class StdioMcp {
|
|
18
|
+
#child: ChildProcess;
|
|
19
|
+
#id = 0;
|
|
20
|
+
#pending = new Map<number, { resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
|
|
21
|
+
#buf = '';
|
|
22
|
+
#closed: Error | null = null;
|
|
23
|
+
#stderr = '';
|
|
24
|
+
#secrets: string[];
|
|
25
|
+
|
|
26
|
+
constructor(server: { command: string; args: string[]; env: Record<string, string>; cwd: string; secrets?: string[] }, baseEnv: NodeJS.ProcessEnv) {
|
|
27
|
+
// The connector's own secrets: the values of the environment it was saved with.
|
|
28
|
+
this.#secrets = [...(server.secrets ?? []), ...Object.values(server.env)].filter((v) => v.length >= 8);
|
|
29
|
+
this.#child = spawn(server.command, server.args, { cwd: server.cwd, env: { ...baseEnv, ...server.env }, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
30
|
+
this.#child.stdout!.setEncoding('utf8');
|
|
31
|
+
this.#child.stdout!.on('data', (chunk: string) => this.#read(chunk));
|
|
32
|
+
this.#child.stderr!.setEncoding('utf8');
|
|
33
|
+
// Redacted as it comes, and only then cut from the front: a secret split across chunks is whole again before
|
|
34
|
+
// the cut, and the cut only ever drops text that was already redacted.
|
|
35
|
+
const keep = Math.max(2000, 2 * Math.max(0, ...this.#secrets.map((v) => v.length)));
|
|
36
|
+
this.#child.stderr!.on('data', (chunk: string) => {
|
|
37
|
+
this.#stderr = redact(this.#stderr + chunk, this.#secrets).slice(-keep);
|
|
38
|
+
});
|
|
39
|
+
this.#child.stdin!.on('error', () => {});
|
|
40
|
+
this.#child.on('error', (err) => this.#fail(new Error(`could not start: ${(err as NodeJS.ErrnoException).code ?? err.message}`)));
|
|
41
|
+
this.#child.on('close', (code) => this.#fail(new Error(`it exited (${code ?? 'killed'})${this.#tail()}`)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The connector's own secrets (see the constructor). */
|
|
45
|
+
get secrets(): string[] {
|
|
46
|
+
return [...this.#secrets];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#tail(): string {
|
|
50
|
+
const t = redact(this.#stderr, this.#secrets).trim().split('\n').pop()?.slice(0, 200) ?? '';
|
|
51
|
+
return t ? `: ${t}` : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#fail(err: Error) {
|
|
55
|
+
this.#closed ??= err;
|
|
56
|
+
for (const [, p] of this.#pending) {
|
|
57
|
+
clearTimeout(p.timer);
|
|
58
|
+
p.reject(this.#closed);
|
|
59
|
+
}
|
|
60
|
+
this.#pending.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#read(chunk: string) {
|
|
64
|
+
this.#buf += chunk;
|
|
65
|
+
if (this.#buf.length > MAX_LINE && !this.#buf.includes('\n')) {
|
|
66
|
+
this.#fail(new Error('it sent a message larger than Botons reads'));
|
|
67
|
+
this.close();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let nl: number;
|
|
71
|
+
while ((nl = this.#buf.indexOf('\n')) >= 0) {
|
|
72
|
+
const line = this.#buf.slice(0, nl).trim();
|
|
73
|
+
this.#buf = this.#buf.slice(nl + 1);
|
|
74
|
+
if (!line) continue;
|
|
75
|
+
let msg: Record<string, unknown>;
|
|
76
|
+
try {
|
|
77
|
+
msg = JSON.parse(line);
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const id = typeof msg.id === 'number' ? msg.id : null;
|
|
82
|
+
const p = id === null ? undefined : this.#pending.get(id);
|
|
83
|
+
if (!p) continue;
|
|
84
|
+
this.#pending.delete(id!);
|
|
85
|
+
clearTimeout(p.timer);
|
|
86
|
+
if (msg.error) {
|
|
87
|
+
const e = msg.error as { message?: unknown };
|
|
88
|
+
p.reject(new Error(typeof e?.message === 'string' ? redact(e.message, this.#secrets).slice(0, 200) : 'error'));
|
|
89
|
+
} else p.resolve(msg.result && typeof msg.result === 'object' && !Array.isArray(msg.result) ? (msg.result as Record<string, unknown>) : {});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#send(msg: Record<string, unknown>) {
|
|
94
|
+
this.#child.stdin!.write(`${JSON.stringify(msg)}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
request(method: string, params: Record<string, unknown>, timeoutMs: number): Promise<Record<string, unknown>> {
|
|
98
|
+
if (this.#closed) return Promise.reject(this.#closed);
|
|
99
|
+
const id = ++this.#id;
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
this.#pending.delete(id);
|
|
103
|
+
reject(new Error(`no answer to ${method} within ${Math.round(timeoutMs / 1000)} s`));
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
106
|
+
this.#send({ jsonrpc: '2.0', id, method, params });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async initialize(timeoutMs: number): Promise<void> {
|
|
111
|
+
await this.request('initialize', { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'Botons', version: VERSION } }, timeoutMs);
|
|
112
|
+
this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async listTools(timeoutMs: number): Promise<McpTool[]> {
|
|
116
|
+
const tools: McpTool[] = [];
|
|
117
|
+
let cursor: unknown;
|
|
118
|
+
for (let page = 0; page < MAX_TOOL_PAGES; page++) {
|
|
119
|
+
const r = await this.request('tools/list', cursor === undefined ? {} : { cursor }, timeoutMs);
|
|
120
|
+
for (const t of Array.isArray(r.tools) ? r.tools : []) {
|
|
121
|
+
if (t && typeof t.name === 'string')
|
|
122
|
+
tools.push({ name: t.name, inputSchema: t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : null, description: typeof t.description === 'string' ? t.description : null });
|
|
123
|
+
}
|
|
124
|
+
if (typeof r.nextCursor !== 'string' || !r.nextCursor) break;
|
|
125
|
+
cursor = r.nextCursor;
|
|
126
|
+
}
|
|
127
|
+
return cleanTools(tools, this.#secrets);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `also`: more to take out of the result before it is cut (see cleanResult). */
|
|
131
|
+
async callTool(name: string, args: Record<string, unknown>, timeoutMs: number, also?: (text: string) => string): Promise<ToolResult> {
|
|
132
|
+
return cleanResult(await this.request('tools/call', { name, arguments: args }, timeoutMs), this.#secrets, also);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
close(): void {
|
|
136
|
+
this.#fail(new Error('closed'));
|
|
137
|
+
try {
|
|
138
|
+
if (this.#child.pid) process.kill(-this.#child.pid, 'SIGTERM');
|
|
139
|
+
} catch {}
|
|
140
|
+
const pid = this.#child.pid;
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
try {
|
|
143
|
+
if (pid) process.kill(-pid, 'SIGKILL');
|
|
144
|
+
} catch {}
|
|
145
|
+
}, 2000).unref();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { setTimeout as wait } from 'node:timers/promises';
|
|
2
|
+
import { type Provider, ProviderError, type ProviderStatus, step, type TurnRequest, type TurnResult } from './types.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* For tests (BOTONS_ENABLE_MOCK=1): replies "Echo: <input>". Triggers in the input: FAIL_PLEASE, SLOW_PLEASE[:ms],
|
|
6
|
+
* QUIET_PLEASE[:note], STEP_PLEASE:[<n>x]<command>, STREAM_PLEASE[:ms].
|
|
7
|
+
*/
|
|
8
|
+
export class MockProvider implements Provider {
|
|
9
|
+
id = 'mock';
|
|
10
|
+
label = 'Mock (tests)';
|
|
11
|
+
askFirst = true;
|
|
12
|
+
turns: TurnRequest[] = [];
|
|
13
|
+
/** A string is the turn's reply; null leaves the turn to the triggers. */
|
|
14
|
+
script: ((req: TurnRequest) => Promise<string | null> | string | null) | null = null;
|
|
15
|
+
#failures = new Map<string, Error[]>();
|
|
16
|
+
|
|
17
|
+
/** The Boton's next turns throw these errors, one per turn. */
|
|
18
|
+
failNext(botId: string, ...errors: Error[]): void {
|
|
19
|
+
this.#failures.set(botId, [...(this.#failures.get(botId) ?? []), ...errors]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async info(): Promise<ProviderStatus> {
|
|
23
|
+
return this.snapshot();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
snapshot(): ProviderStatus {
|
|
27
|
+
return {
|
|
28
|
+
id: this.id,
|
|
29
|
+
label: this.label,
|
|
30
|
+
kind: 'api',
|
|
31
|
+
available: true,
|
|
32
|
+
reason: null,
|
|
33
|
+
installed: true,
|
|
34
|
+
version: null,
|
|
35
|
+
loggedIn: true,
|
|
36
|
+
checkedAt: new Date().toISOString(),
|
|
37
|
+
models: [{ id: '', label: 'Echo' }],
|
|
38
|
+
defaultModel: '',
|
|
39
|
+
askFirst: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async runTurn(req: TurnRequest): Promise<TurnResult> {
|
|
44
|
+
this.turns.push(req);
|
|
45
|
+
const fail = this.#failures.get(req.env.BOTON_BOT_ID ?? '')?.shift();
|
|
46
|
+
if (fail) throw fail;
|
|
47
|
+
req.onActivity('Thinking');
|
|
48
|
+
const started = Date.now();
|
|
49
|
+
const scripted = this.script ? await this.script(req) : null;
|
|
50
|
+
if (scripted !== null) return { sessionId: req.sessionId ?? `mock-${this.turns.length}`, text: scripted };
|
|
51
|
+
// Only this turn's input, not the context block or a replayed transcript.
|
|
52
|
+
const body = req.prompt.split('</boton_context>').pop()!.trim();
|
|
53
|
+
for (const m of body.matchAll(/STEP_PLEASE:(?:(\d+)x)?([^\n]+)/g)) {
|
|
54
|
+
const command = m[2].trim();
|
|
55
|
+
for (let i = 0; i < Number(m[1] ?? 1); i++) {
|
|
56
|
+
req.onActivity(`Running \`${command}\``);
|
|
57
|
+
req.onStep?.(step('command', `Running \`${command.slice(0, 70)}\``, command));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const slow = /SLOW_PLEASE(?::(\d+))?/.exec(body);
|
|
61
|
+
await work(slow ? Number(slow[1] ?? 30_000) : 50, req, started);
|
|
62
|
+
if (req.prompt.includes('FAIL_PLEASE')) throw new Error('Mock failure requested');
|
|
63
|
+
const quiet = /QUIET_PLEASE(?::([^\n]*))?/.exec(body);
|
|
64
|
+
const text = quiet ? `[quiet]${quiet[1] ? ` ${quiet[1].trim()}` : ''}` : `Echo: ${body.slice(0, 500)}`;
|
|
65
|
+
const stream = /STREAM_PLEASE(?::(\d+))?/.exec(body);
|
|
66
|
+
if (stream) {
|
|
67
|
+
const words = text.split(/(?<=\s)/);
|
|
68
|
+
for (let i = 1; i <= words.length; i++) {
|
|
69
|
+
req.onText?.(words.slice(0, i).join(''));
|
|
70
|
+
await work(Number(stream[1] ?? 40), req, started);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const usage = { inputTokens: Math.ceil(req.prompt.length / 4), outputTokens: Math.ceil(text.length / 4), cacheReadTokens: 0, cacheWriteTokens: 0, model: req.model || 'mock-echo' };
|
|
74
|
+
return { sessionId: req.sessionId ?? `mock-${this.turns.length}`, text, usage };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Waits `ms` as a CLI turn would run, ending it as runJsonl does: stopped by `req.signal`, or timed out at
|
|
80
|
+
* `req.timeoutMs` from `started`, whichever comes first.
|
|
81
|
+
*/
|
|
82
|
+
async function work(ms: number, req: TurnRequest, started: number): Promise<void> {
|
|
83
|
+
const left = started + req.timeoutMs - Date.now();
|
|
84
|
+
await sleep(Math.min(ms, Math.max(0, left)), req.signal);
|
|
85
|
+
if (req.signal.aborted) throw new Error('Stopped.');
|
|
86
|
+
if (ms >= left) {
|
|
87
|
+
const err = new ProviderError('Mock timed out.', false);
|
|
88
|
+
err.exit = { exitCode: null, signal: 'SIGTERM', timedOut: true, stderrTail: '' };
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Wait `ms`, or less if `signal` aborts first. */
|
|
94
|
+
const sleep = (ms: number, signal: AbortSignal) => wait(ms, undefined, { signal }).catch(() => {});
|