@owloops/browserbird 1.0.1 → 1.0.3

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.
Files changed (56) hide show
  1. package/bin/browserbird +7 -1
  2. package/dist/db-BsYEYsul.mjs +1011 -0
  3. package/dist/index.mjs +4748 -0
  4. package/package.json +6 -3
  5. package/src/channel/blocks.ts +0 -485
  6. package/src/channel/coalesce.ts +0 -79
  7. package/src/channel/commands.ts +0 -216
  8. package/src/channel/handler.ts +0 -272
  9. package/src/channel/slack.ts +0 -573
  10. package/src/channel/types.ts +0 -59
  11. package/src/cli/banner.ts +0 -10
  12. package/src/cli/birds.ts +0 -396
  13. package/src/cli/config.ts +0 -77
  14. package/src/cli/doctor.ts +0 -63
  15. package/src/cli/index.ts +0 -5
  16. package/src/cli/jobs.ts +0 -166
  17. package/src/cli/logs.ts +0 -67
  18. package/src/cli/run.ts +0 -148
  19. package/src/cli/sessions.ts +0 -158
  20. package/src/cli/style.ts +0 -19
  21. package/src/config.ts +0 -291
  22. package/src/core/logger.ts +0 -78
  23. package/src/core/redact.ts +0 -75
  24. package/src/core/types.ts +0 -83
  25. package/src/core/uid.ts +0 -26
  26. package/src/core/utils.ts +0 -137
  27. package/src/cron/parse.ts +0 -146
  28. package/src/cron/scheduler.ts +0 -242
  29. package/src/daemon.ts +0 -169
  30. package/src/db/auth.ts +0 -49
  31. package/src/db/birds.ts +0 -357
  32. package/src/db/core.ts +0 -377
  33. package/src/db/index.ts +0 -10
  34. package/src/db/jobs.ts +0 -289
  35. package/src/db/logs.ts +0 -64
  36. package/src/db/messages.ts +0 -79
  37. package/src/db/path.ts +0 -30
  38. package/src/db/sessions.ts +0 -165
  39. package/src/jobs.ts +0 -140
  40. package/src/provider/claude.test.ts +0 -95
  41. package/src/provider/claude.ts +0 -196
  42. package/src/provider/opencode.test.ts +0 -169
  43. package/src/provider/opencode.ts +0 -248
  44. package/src/provider/session.ts +0 -65
  45. package/src/provider/spawn.ts +0 -173
  46. package/src/provider/stream.ts +0 -67
  47. package/src/provider/types.ts +0 -24
  48. package/src/server/auth.ts +0 -135
  49. package/src/server/health.ts +0 -87
  50. package/src/server/http.ts +0 -132
  51. package/src/server/index.ts +0 -6
  52. package/src/server/lifecycle.ts +0 -135
  53. package/src/server/routes.ts +0 -1199
  54. package/src/server/sse.ts +0 -54
  55. package/src/server/static.ts +0 -45
  56. package/src/server/vnc-proxy.ts +0 -75
@@ -1,216 +0,0 @@
1
- /** @fileoverview Slash command handler for `/bird` commands in Slack. */
2
-
3
- import type { WebClient } from '@slack/web-api';
4
- import type { Config } from '../core/types.ts';
5
- import type { ChannelClient } from './types.ts';
6
-
7
- import * as db from '../db/index.ts';
8
- import { enqueue } from '../jobs.ts';
9
- import { logger } from '../core/logger.ts';
10
- import {
11
- birdListBlocks,
12
- birdFlyBlocks,
13
- birdLogsBlocks,
14
- birdCreateModal,
15
- statusBlocks,
16
- } from './blocks.ts';
17
-
18
- export interface SlashCommandBody {
19
- command: string;
20
- text: string;
21
- trigger_id: string;
22
- user_id: string;
23
- channel_id: string;
24
- team_id: string;
25
- }
26
-
27
- export interface StatusProvider {
28
- slackConnected: () => boolean;
29
- activeCount: () => number;
30
- }
31
-
32
- const startTime = Date.now();
33
-
34
- function formatUptime(): string {
35
- const ms = Date.now() - startTime;
36
- const hours = Math.floor(ms / 3_600_000);
37
- const minutes = Math.floor((ms % 3_600_000) / 60_000);
38
- if (hours === 0) return `${minutes}m`;
39
- return `${hours}h ${minutes}m`;
40
- }
41
-
42
- export async function handleSlashCommand(
43
- body: SlashCommandBody,
44
- webClient: WebClient,
45
- channelClient: ChannelClient,
46
- config: Config,
47
- status: StatusProvider,
48
- ): Promise<void> {
49
- const parts = body.text.trim().split(/\s+/);
50
- const subcommand = parts[0] ?? 'help';
51
-
52
- async function say(message: { text: string; blocks?: unknown[] }): Promise<void> {
53
- await webClient.chat.postMessage({
54
- channel: body.channel_id,
55
- text: message.text,
56
- ...(message.blocks ? { blocks: message.blocks } : {}),
57
- });
58
- }
59
-
60
- function findBird(nameOrUid: string): db.CronJobRow | undefined {
61
- const byUid = db.resolveByUid<db.CronJobRow>('cron_jobs', nameOrUid);
62
- if (byUid && 'row' in byUid) return byUid.row;
63
- const result = db.listCronJobs(1, 100, false);
64
- return result.items.find((b) => b.name === nameOrUid);
65
- }
66
-
67
- switch (subcommand) {
68
- case 'list': {
69
- const result = db.listCronJobs(1, 20, false);
70
- const birds = result.items.map((b) => ({
71
- uid: b.uid,
72
- name: b.name,
73
- schedule: b.schedule,
74
- enabled: b.enabled === 1,
75
- lastStatus: b.last_status,
76
- agentId: b.agent_id,
77
- }));
78
- const blocks = birdListBlocks(birds);
79
- await say({
80
- text: `${birds.length} bird${birds.length === 1 ? '' : 's'} configured`,
81
- blocks,
82
- });
83
- break;
84
- }
85
-
86
- case 'fly': {
87
- const birdName = parts.slice(1).join(' ');
88
- if (!birdName) {
89
- await say({ text: 'Usage: `/bird fly <name or id>`' });
90
- return;
91
- }
92
-
93
- const bird = findBird(birdName);
94
- if (!bird) {
95
- await say({ text: `Bird not found: \`${birdName}\`` });
96
- return;
97
- }
98
-
99
- enqueue(
100
- 'cron_run',
101
- {
102
- cronJobUid: bird.uid,
103
- prompt: bird.prompt,
104
- channelId: bird.target_channel_id,
105
- agentId: bird.agent_id,
106
- },
107
- { maxAttempts: config.birds.maxAttempts, timeout: 600, cronJobUid: bird.uid },
108
- );
109
-
110
- const blocks = birdFlyBlocks(bird.name, body.user_id);
111
- await say({ text: `${bird.name} is taking flight...`, blocks });
112
- logger.info(`/bird fly: ${bird.name} triggered by ${body.user_id}`);
113
- break;
114
- }
115
-
116
- case 'enable':
117
- case 'disable': {
118
- const birdName = parts.slice(1).join(' ');
119
- if (!birdName) {
120
- await say({ text: `Usage: \`/bird ${subcommand} <name or id>\`` });
121
- return;
122
- }
123
-
124
- const bird = findBird(birdName);
125
- if (!bird) {
126
- await say({ text: `Bird not found: \`${birdName}\`` });
127
- return;
128
- }
129
-
130
- const enabling = subcommand === 'enable';
131
- const alreadyInState = (bird.enabled === 1) === enabling;
132
- if (alreadyInState) {
133
- await say({ text: `*${bird.name}* is already ${subcommand}d.` });
134
- return;
135
- }
136
-
137
- db.setCronJobEnabled(bird.uid, enabling);
138
- await say({ text: `*${bird.name}* ${subcommand}d.` });
139
- logger.info(`/bird ${subcommand}: ${bird.name} by ${body.user_id}`);
140
- break;
141
- }
142
-
143
- case 'logs': {
144
- const birdName = parts.slice(1).join(' ');
145
- if (!birdName) {
146
- await say({ text: 'Usage: `/bird logs <name or id>`' });
147
- return;
148
- }
149
-
150
- const bird = findBird(birdName);
151
- if (!bird) {
152
- await say({ text: `Bird not found: \`${birdName}\`` });
153
- return;
154
- }
155
-
156
- const flights = db.listFlights(1, 5, { birdUid: bird.uid });
157
- const mapped = flights.items.map((f) => {
158
- const durationMs =
159
- f.finished_at && f.started_at
160
- ? new Date(f.finished_at).getTime() - new Date(f.started_at).getTime()
161
- : undefined;
162
- return {
163
- uid: f.uid,
164
- status: f.status,
165
- startedAt: f.started_at,
166
- durationMs,
167
- error: f.error ?? undefined,
168
- };
169
- });
170
-
171
- const blocks = birdLogsBlocks(bird.name, mapped);
172
- const text = `${flights.totalItems} flight${flights.totalItems === 1 ? '' : 's'} for ${bird.name}`;
173
- await say({ text, blocks });
174
- break;
175
- }
176
-
177
- case 'create': {
178
- if (!channelClient.openModal) {
179
- await say({ text: 'Modals are not supported by this adapter.' });
180
- return;
181
- }
182
-
183
- const modal = birdCreateModal();
184
- await channelClient.openModal(body.trigger_id, modal);
185
- break;
186
- }
187
-
188
- case 'status': {
189
- const cronJobs = db.listCronJobs(1, 1, false);
190
- const blocks = statusBlocks({
191
- slackConnected: status.slackConnected(),
192
- activeCount: status.activeCount(),
193
- maxConcurrent: config.sessions.maxConcurrent,
194
- birdCount: cronJobs.totalItems,
195
- uptime: formatUptime(),
196
- });
197
- await say({ text: 'BrowserBird status', blocks });
198
- break;
199
- }
200
-
201
- default:
202
- await say({
203
- text: [
204
- '*Usage:* `/bird <command>`',
205
- '',
206
- '`/bird list` - Show all configured birds',
207
- '`/bird fly <name>` - Trigger a bird immediately',
208
- '`/bird logs <name>` - Show recent flights',
209
- '`/bird enable <name>` - Enable a bird',
210
- '`/bird disable <name>` - Disable a bird',
211
- '`/bird create` - Create a new bird (opens form)',
212
- '`/bird status` - Show daemon status',
213
- ].join('\n'),
214
- });
215
- }
216
- }
@@ -1,272 +0,0 @@
1
- /** @fileoverview Core orchestration: session resolution, spawn, stream-to-channel. */
2
-
3
- import type { Config } from '../core/types.ts';
4
- import type { CoalesceDispatch } from './coalesce.ts';
5
- import type { StreamEvent, StreamEventCompletion, ToolImage } from '../provider/stream.ts';
6
- import type { ChannelClient } from './types.ts';
7
-
8
- import { resolveSession } from '../provider/session.ts';
9
- import { spawnProvider } from '../provider/spawn.ts';
10
- import * as db from '../db/index.ts';
11
- import { logger } from '../core/logger.ts';
12
- import { redact } from '../core/redact.ts';
13
- import { broadcastSSE } from '../server/index.ts';
14
- import { sessionErrorBlocks, busyBlocks, noAgentBlocks, completionFooterBlocks } from './blocks.ts';
15
-
16
- interface SessionLock {
17
- processing: boolean;
18
- queue: CoalesceDispatch[];
19
- killCurrent: (() => void) | null;
20
- }
21
-
22
- export interface Handler {
23
- handle(dispatch: CoalesceDispatch): Promise<void>;
24
- activeCount(): number;
25
- killAll(): void;
26
- }
27
-
28
- export function createHandler(
29
- client: ChannelClient,
30
- config: Config,
31
- signal: AbortSignal,
32
- getTeamId: () => string,
33
- ): Handler {
34
- const locks = new Map<string, SessionLock>();
35
- let activeSpawns = 0;
36
-
37
- function getLock(key: string): SessionLock {
38
- let lock = locks.get(key);
39
- if (!lock) {
40
- lock = { processing: false, queue: [], killCurrent: null };
41
- locks.set(key, lock);
42
- }
43
- return lock;
44
- }
45
-
46
- function formatPrompt(messages: CoalesceDispatch['messages']): string {
47
- if (messages.length === 1) {
48
- return messages[0]!.text;
49
- }
50
- return messages
51
- .map((m) => {
52
- const time = new Date(Number(m.timestamp) * 1000).toISOString().slice(11, 19);
53
- return `[${time}] @${m.userId}: ${m.text}`;
54
- })
55
- .join('\n');
56
- }
57
-
58
- async function streamToChannel(
59
- events: AsyncIterable<StreamEvent>,
60
- channelId: string,
61
- threadTs: string,
62
- sessionUid: string,
63
- teamId: string,
64
- userId: string,
65
- meta: { birdName?: string },
66
- ): Promise<void> {
67
- const streamer = client.startStream({ channelId, threadTs, teamId, userId });
68
- let fullText = '';
69
- let completion: StreamEventCompletion | undefined;
70
- let hasError = false;
71
-
72
- for await (const event of events) {
73
- if (signal.aborted) break;
74
- logger.debug(`stream event: ${event.type}`);
75
-
76
- switch (event.type) {
77
- case 'init':
78
- db.updateSessionProviderId(sessionUid, event.sessionId);
79
- break;
80
-
81
- case 'text_delta': {
82
- const safe = redact(event.delta);
83
- fullText += safe;
84
- await streamer.append({ markdown_text: safe });
85
- break;
86
- }
87
-
88
- case 'tool_images':
89
- await uploadImages(event.images, channelId, threadTs);
90
- break;
91
-
92
- case 'completion':
93
- completion = event;
94
- logger.info(
95
- `completion [${event.subtype}]: ${event.tokensIn}in/${event.tokensOut}out, $${event.costUsd.toFixed(4)}, ${event.numTurns} turns`,
96
- );
97
- db.logMessage(
98
- channelId,
99
- threadTs,
100
- 'bot',
101
- 'out',
102
- fullText,
103
- event.tokensIn,
104
- event.tokensOut,
105
- );
106
- break;
107
-
108
- case 'rate_limit':
109
- logger.debug(`rate limit window resets ${new Date(event.resetsAt * 1000).toISOString()}`);
110
- break;
111
-
112
- case 'error': {
113
- hasError = true;
114
- const safeError = redact(event.error);
115
- logger.error(`agent error: ${safeError}`);
116
- db.insertLog('error', 'spawn', safeError, channelId);
117
- await streamer.append({ markdown_text: `\n\nError: ${safeError}` });
118
- break;
119
- }
120
- }
121
- }
122
-
123
- const footerBlocks = completion
124
- ? completionFooterBlocks(completion, hasError, meta.birdName, userId)
125
- : undefined;
126
-
127
- await streamer.stop(footerBlocks ? { blocks: footerBlocks } : {});
128
- }
129
-
130
- async function uploadImages(
131
- images: ToolImage[],
132
- channelId: string,
133
- threadTs: string,
134
- ): Promise<void> {
135
- for (let i = 0; i < images.length; i++) {
136
- const img = images[i]!;
137
- const content = Buffer.from(img.data, 'base64');
138
- const ext = img.mediaType === 'image/jpeg' ? 'jpg' : 'png';
139
- const filename = `screenshot-${i + 1}.${ext}`;
140
- try {
141
- await client.uploadFile(channelId, threadTs, content, filename, `Screenshot ${i + 1}`);
142
- } catch (err) {
143
- logger.warn(`image upload failed: ${err instanceof Error ? err.message : String(err)}`);
144
- }
145
- }
146
- }
147
-
148
- async function handle(dispatch: CoalesceDispatch): Promise<void> {
149
- const { channelId, threadTs, messages } = dispatch;
150
- const key = `${channelId}:${threadTs}`;
151
- const lock = getLock(key);
152
-
153
- if (lock.processing) {
154
- lock.queue.push(dispatch);
155
- try {
156
- await client.postEphemeral(
157
- channelId,
158
- threadTs,
159
- messages[messages.length - 1]!.userId,
160
- "Got it, I'll get to this after my current response.",
161
- );
162
- } catch {
163
- /* postEphemeral may fail if user left channel */
164
- }
165
- return;
166
- }
167
-
168
- if (activeSpawns >= config.sessions.maxConcurrent) {
169
- const blocks = busyBlocks(activeSpawns, config.sessions.maxConcurrent);
170
- await client.postMessage(
171
- channelId,
172
- threadTs,
173
- 'Too many active sessions. Try again shortly.',
174
- { blocks },
175
- );
176
- logger.warn('max concurrent sessions reached');
177
- return;
178
- }
179
-
180
- lock.processing = true;
181
- activeSpawns++;
182
-
183
- let sessionUid: string | undefined;
184
- try {
185
- const resolved = resolveSession(channelId, threadTs, config);
186
- if (!resolved) {
187
- const blocks = noAgentBlocks(channelId);
188
- await client.postMessage(channelId, threadTs, 'No agent configured for this channel.', {
189
- blocks,
190
- });
191
- return;
192
- }
193
-
194
- const { session, agent, isNew } = resolved;
195
- sessionUid = session.uid;
196
-
197
- for (const msg of messages) {
198
- db.logMessage(channelId, threadTs, msg.userId, 'in', msg.text);
199
- }
200
- db.touchSession(session.uid, messages.length + 1);
201
- broadcastSSE('invalidate', { resource: 'sessions' });
202
-
203
- const prompt = formatPrompt(messages);
204
- const lastMessage = messages[messages.length - 1]!;
205
- const userId = lastMessage.userId;
206
-
207
- const existingSessionId = isNew ? undefined : session.provider_session_id || undefined;
208
- const { events, kill } = spawnProvider(
209
- agent.provider,
210
- {
211
- message: prompt,
212
- sessionId: existingSessionId,
213
- agent,
214
- mcpConfigPath: config.browser.mcpConfigPath,
215
- },
216
- signal,
217
- );
218
-
219
- lock.killCurrent = kill;
220
-
221
- client.setStatus?.(channelId, threadTs, 'is thinking...').catch(() => {});
222
-
223
- if (isNew) {
224
- const title = prompt.length > 60 ? prompt.slice(0, 57) + '...' : prompt;
225
- client.setTitle?.(channelId, threadTs, title).catch(() => {});
226
- }
227
-
228
- await streamToChannel(events, channelId, threadTs, session.uid, getTeamId(), userId, {
229
- birdName: agent.name,
230
- });
231
- } catch (err) {
232
- const errMsg = err instanceof Error ? err.message : String(err);
233
- logger.error(`handler error: ${errMsg}`);
234
- db.insertLog('error', 'handler', errMsg, channelId);
235
- try {
236
- const blocks = sessionErrorBlocks(errMsg, { sessionUid });
237
- await client.postMessage(channelId, threadTs, `Something went wrong: ${errMsg}`, {
238
- blocks,
239
- });
240
- } catch {
241
- /* channel may no longer be accessible */
242
- }
243
- } finally {
244
- activeSpawns--;
245
- lock.processing = false;
246
- lock.killCurrent = null;
247
-
248
- const next = lock.queue.shift();
249
- if (next) {
250
- handle(next).catch((err: unknown) => {
251
- logger.error(`dispatch error: ${err instanceof Error ? err.message : String(err)}`);
252
- });
253
- } else if (lock.queue.length === 0) {
254
- locks.delete(key);
255
- }
256
- }
257
- }
258
-
259
- function activeCount(): number {
260
- return activeSpawns;
261
- }
262
-
263
- function killAll(): void {
264
- for (const lock of locks.values()) {
265
- lock.killCurrent?.();
266
- lock.queue.length = 0;
267
- }
268
- locks.clear();
269
- }
270
-
271
- return { handle, activeCount, killAll };
272
- }