@owloops/browserbird 1.0.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.
Files changed (63) hide show
  1. package/LICENSE +106 -0
  2. package/README.md +329 -0
  3. package/bin/browserbird +11 -0
  4. package/package.json +68 -0
  5. package/src/channel/blocks.ts +485 -0
  6. package/src/channel/coalesce.ts +79 -0
  7. package/src/channel/commands.ts +216 -0
  8. package/src/channel/handler.ts +272 -0
  9. package/src/channel/slack.ts +573 -0
  10. package/src/channel/types.ts +59 -0
  11. package/src/cli/banner.ts +10 -0
  12. package/src/cli/birds.ts +396 -0
  13. package/src/cli/config.ts +77 -0
  14. package/src/cli/doctor.ts +63 -0
  15. package/src/cli/index.ts +5 -0
  16. package/src/cli/jobs.ts +166 -0
  17. package/src/cli/logs.ts +67 -0
  18. package/src/cli/run.ts +148 -0
  19. package/src/cli/sessions.ts +158 -0
  20. package/src/cli/style.ts +19 -0
  21. package/src/config.ts +291 -0
  22. package/src/core/logger.ts +78 -0
  23. package/src/core/redact.ts +75 -0
  24. package/src/core/types.ts +83 -0
  25. package/src/core/uid.ts +26 -0
  26. package/src/core/utils.ts +137 -0
  27. package/src/cron/parse.ts +146 -0
  28. package/src/cron/scheduler.ts +242 -0
  29. package/src/daemon.ts +169 -0
  30. package/src/db/auth.ts +49 -0
  31. package/src/db/birds.ts +357 -0
  32. package/src/db/core.ts +377 -0
  33. package/src/db/index.ts +10 -0
  34. package/src/db/jobs.ts +289 -0
  35. package/src/db/logs.ts +64 -0
  36. package/src/db/messages.ts +79 -0
  37. package/src/db/path.ts +30 -0
  38. package/src/db/sessions.ts +165 -0
  39. package/src/jobs.ts +140 -0
  40. package/src/provider/claude.test.ts +95 -0
  41. package/src/provider/claude.ts +196 -0
  42. package/src/provider/opencode.test.ts +169 -0
  43. package/src/provider/opencode.ts +248 -0
  44. package/src/provider/session.ts +65 -0
  45. package/src/provider/spawn.ts +173 -0
  46. package/src/provider/stream.ts +67 -0
  47. package/src/provider/types.ts +24 -0
  48. package/src/server/auth.ts +135 -0
  49. package/src/server/health.ts +87 -0
  50. package/src/server/http.ts +132 -0
  51. package/src/server/index.ts +6 -0
  52. package/src/server/lifecycle.ts +135 -0
  53. package/src/server/routes.ts +1199 -0
  54. package/src/server/sse.ts +54 -0
  55. package/src/server/static.ts +45 -0
  56. package/src/server/vnc-proxy.ts +75 -0
  57. package/web/dist/assets/index-C6MBAUmO.js +7 -0
  58. package/web/dist/assets/index-JMPJCJ2F.css +1 -0
  59. package/web/dist/favicon.svg +5 -0
  60. package/web/dist/index.html +20 -0
  61. package/web/dist/logo-icon.png +0 -0
  62. package/web/dist/logo-icon.svg +5 -0
  63. package/web/dist/logo.svg +7 -0
package/src/db/logs.ts ADDED
@@ -0,0 +1,64 @@
1
+ /** @fileoverview Structured log persistence: queryable via web UI. */
2
+
3
+ import type { PaginatedResult } from './core.ts';
4
+ import { getDb, paginate, DEFAULT_PER_PAGE } from './core.ts';
5
+
6
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
7
+
8
+ export interface LogRow {
9
+ id: number;
10
+ level: LogLevel;
11
+ source: string;
12
+ message: string;
13
+ channel_id: string | null;
14
+ created_at: string;
15
+ }
16
+
17
+ export function insertLog(
18
+ level: LogLevel,
19
+ source: string,
20
+ message: string,
21
+ channelId?: string,
22
+ ): void {
23
+ getDb()
24
+ .prepare(`INSERT INTO logs (level, source, message, channel_id) VALUES (?, ?, ?, ?)`)
25
+ .run(level, source, message, channelId ?? null);
26
+ }
27
+
28
+ const LOG_SORT_COLUMNS = new Set(['id', 'level', 'source', 'created_at']);
29
+ const LOG_SEARCH_COLUMNS = ['message', 'source'] as const;
30
+
31
+ export function getRecentLogs(
32
+ page = 1,
33
+ perPage = DEFAULT_PER_PAGE,
34
+ level?: string,
35
+ source?: string,
36
+ sort?: string,
37
+ search?: string,
38
+ ): PaginatedResult<LogRow> {
39
+ const conditions: string[] = [];
40
+ const params: (string | number)[] = [];
41
+ if (level) {
42
+ conditions.push('level = ?');
43
+ params.push(level);
44
+ }
45
+ if (source) {
46
+ conditions.push('source = ?');
47
+ params.push(source);
48
+ }
49
+ const where = conditions.join(' AND ');
50
+ return paginate<LogRow>('logs', page, perPage, {
51
+ where,
52
+ params,
53
+ defaultSort: 'created_at DESC',
54
+ sort,
55
+ search,
56
+ allowedSortColumns: LOG_SORT_COLUMNS,
57
+ searchColumns: LOG_SEARCH_COLUMNS,
58
+ });
59
+ }
60
+
61
+ export function deleteOldLogs(retentionDays: number): number {
62
+ const stmt = getDb().prepare(`DELETE FROM logs WHERE created_at < datetime('now', ? || ' days')`);
63
+ return Number(stmt.run(`-${retentionDays}`).changes);
64
+ }
@@ -0,0 +1,79 @@
1
+ /** @fileoverview Message logging: message audit trail and token tracking. */
2
+
3
+ import { getDb } from './core.ts';
4
+
5
+ export interface MessageRow {
6
+ id: number;
7
+ channel_id: string;
8
+ thread_id: string | null;
9
+ user_id: string;
10
+ direction: 'in' | 'out';
11
+ content: string | null;
12
+ tokens_in: number | null;
13
+ tokens_out: number | null;
14
+ created_at: string;
15
+ }
16
+
17
+ export function logMessage(
18
+ channelId: string,
19
+ threadId: string | null,
20
+ userId: string,
21
+ direction: 'in' | 'out',
22
+ content?: string,
23
+ tokensIn?: number,
24
+ tokensOut?: number,
25
+ ): void {
26
+ const stmt = getDb().prepare(
27
+ `INSERT INTO messages (channel_id, thread_id, user_id, direction, content, tokens_in, tokens_out)
28
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
29
+ );
30
+ stmt.run(
31
+ channelId,
32
+ threadId ?? null,
33
+ userId,
34
+ direction,
35
+ content ?? null,
36
+ tokensIn ?? null,
37
+ tokensOut ?? null,
38
+ );
39
+ }
40
+
41
+ export function getLastInboundMessage(
42
+ channelId: string,
43
+ threadId: string | null,
44
+ ): { content: string; timestamp: string } | undefined {
45
+ const row = getDb()
46
+ .prepare(
47
+ `SELECT content, created_at FROM messages
48
+ WHERE channel_id = ? AND thread_id IS ? AND direction = 'in' AND content IS NOT NULL
49
+ ORDER BY created_at DESC LIMIT 1`,
50
+ )
51
+ .get(channelId, threadId) as unknown as { content: string; created_at: string } | undefined;
52
+ if (!row) return undefined;
53
+ return { content: row.content, timestamp: row.created_at };
54
+ }
55
+
56
+ export function deleteOldMessages(retentionDays: number): number {
57
+ const stmt = getDb().prepare(
58
+ `DELETE FROM messages WHERE created_at < datetime('now', ? || ' days')`,
59
+ );
60
+ const result = stmt.run(`-${retentionDays}`);
61
+ return Number(result.changes);
62
+ }
63
+
64
+ export function getMessageStats(): {
65
+ totalMessages: number;
66
+ totalTokensIn: number;
67
+ totalTokensOut: number;
68
+ } {
69
+ const row = getDb()
70
+ .prepare(
71
+ `SELECT
72
+ COUNT(*) as totalMessages,
73
+ COALESCE(SUM(tokens_in), 0) as totalTokensIn,
74
+ COALESCE(SUM(tokens_out), 0) as totalTokensOut
75
+ FROM messages`,
76
+ )
77
+ .get() as unknown as { totalMessages: number; totalTokensIn: number; totalTokensOut: number };
78
+ return row;
79
+ }
package/src/db/path.ts ADDED
@@ -0,0 +1,30 @@
1
+ /** @fileoverview Database path resolution: CLI flag, env var, or default. */
2
+
3
+ import { resolve } from 'node:path';
4
+
5
+ const DEFAULT_DB_PATH = resolve('.browserbird', 'browserbird.db');
6
+
7
+ /**
8
+ * Resolves the database file path.
9
+ * Priority: explicit value > BROWSERBIRD_DB env var > default.
10
+ */
11
+ export function resolveDbPath(explicit?: string): string {
12
+ if (explicit) return resolve(explicit);
13
+
14
+ const envValue = process.env['BROWSERBIRD_DB'];
15
+ if (envValue) return resolve(envValue);
16
+
17
+ return DEFAULT_DB_PATH;
18
+ }
19
+
20
+ /**
21
+ * Extracts --db value from a raw argv array, then resolves.
22
+ * Used by CLI command handlers that receive argv directly.
23
+ */
24
+ export function resolveDbPathFromArgv(argv: string[]): string {
25
+ const idx = argv.indexOf('--db');
26
+ if (idx !== -1 && idx + 1 < argv.length) {
27
+ return resolveDbPath(argv[idx + 1]);
28
+ }
29
+ return resolveDbPath();
30
+ }
@@ -0,0 +1,165 @@
1
+ /** @fileoverview Session persistence: channel-to-agent session mapping. */
2
+
3
+ import type { PaginatedResult } from './core.ts';
4
+ import type { MessageRow } from './messages.ts';
5
+ import {
6
+ getDb,
7
+ paginate,
8
+ parseSort,
9
+ buildSearchClause,
10
+ DEFAULT_PER_PAGE,
11
+ MAX_PER_PAGE,
12
+ } from './core.ts';
13
+ import { generateUid, UID_PREFIX } from '../core/uid.ts';
14
+
15
+ export interface SessionRow {
16
+ uid: string;
17
+ channel_id: string;
18
+ thread_id: string | null;
19
+ agent_id: string;
20
+ provider_session_id: string;
21
+ created_at: string;
22
+ last_active: string;
23
+ message_count: number;
24
+ }
25
+
26
+ export function findSession(channelId: string, threadId: string | null): SessionRow | undefined {
27
+ const stmt = getDb().prepare('SELECT * FROM sessions WHERE channel_id = ? AND thread_id IS ?');
28
+ return stmt.get(channelId, threadId) as unknown as SessionRow | undefined;
29
+ }
30
+
31
+ export function createSession(
32
+ channelId: string,
33
+ threadId: string | null,
34
+ agentId: string,
35
+ providerSessionId: string,
36
+ ): SessionRow {
37
+ const uid = generateUid(UID_PREFIX.session);
38
+ const stmt = getDb().prepare(
39
+ `INSERT INTO sessions (uid, channel_id, thread_id, agent_id, provider_session_id)
40
+ VALUES (?, ?, ?, ?, ?)
41
+ RETURNING *`,
42
+ );
43
+ return stmt.get(uid, channelId, threadId, agentId, providerSessionId) as unknown as SessionRow;
44
+ }
45
+
46
+ export function touchSession(uid: string, messageCountDelta = 1): void {
47
+ const stmt = getDb().prepare(
48
+ `UPDATE sessions SET last_active = datetime('now'), message_count = message_count + ? WHERE uid = ?`,
49
+ );
50
+ stmt.run(messageCountDelta, uid);
51
+ }
52
+
53
+ const SESSION_SORT_COLUMNS = new Set([
54
+ 'uid',
55
+ 'channel_id',
56
+ 'agent_id',
57
+ 'message_count',
58
+ 'last_active',
59
+ 'created_at',
60
+ ]);
61
+ const SESSION_SEARCH_COLUMNS = ['channel_id', 'thread_id', 'agent_id'] as const;
62
+
63
+ export function listSessions(
64
+ page = 1,
65
+ perPage = DEFAULT_PER_PAGE,
66
+ sort?: string,
67
+ search?: string,
68
+ ): PaginatedResult<SessionRow> {
69
+ return paginate<SessionRow>('sessions', page, perPage, {
70
+ defaultSort: 'last_active DESC',
71
+ sort,
72
+ search,
73
+ allowedSortColumns: SESSION_SORT_COLUMNS,
74
+ searchColumns: SESSION_SEARCH_COLUMNS,
75
+ });
76
+ }
77
+
78
+ export function getSession(uid: string): SessionRow | undefined {
79
+ return getDb().prepare('SELECT * FROM sessions WHERE uid = ?').get(uid) as unknown as
80
+ | SessionRow
81
+ | undefined;
82
+ }
83
+
84
+ const MESSAGE_SORT_COLUMNS = new Set(['id', 'created_at', 'direction', 'user_id']);
85
+ const MESSAGE_SEARCH_COLUMNS = ['content', 'user_id'] as const;
86
+
87
+ export function getSessionMessages(
88
+ channelId: string,
89
+ threadId: string | null,
90
+ page = 1,
91
+ perPage = DEFAULT_PER_PAGE,
92
+ sort?: string,
93
+ search?: string,
94
+ ): PaginatedResult<MessageRow> {
95
+ const pp = Math.min(Math.max(perPage, 1), MAX_PER_PAGE);
96
+ const p = Math.max(page, 1);
97
+ const offset = (p - 1) * pp;
98
+
99
+ const conditions = ['channel_id = ? AND thread_id IS ?'];
100
+ const allParams: (string | number)[] = [channelId, threadId as string];
101
+
102
+ if (search) {
103
+ const sc = buildSearchClause(search, MESSAGE_SEARCH_COLUMNS);
104
+ if (sc.sql) {
105
+ conditions.push(sc.sql);
106
+ allParams.push(...sc.params);
107
+ }
108
+ }
109
+
110
+ const where = conditions.join(' AND ');
111
+ const orderBy = parseSort(sort, MESSAGE_SORT_COLUMNS, 'created_at ASC, id ASC');
112
+
113
+ const countRow = getDb()
114
+ .prepare(`SELECT COUNT(*) as count FROM messages WHERE ${where}`)
115
+ .get(...allParams) as unknown as { count: number };
116
+
117
+ const totalItems = countRow.count;
118
+ const totalPages = Math.max(Math.ceil(totalItems / pp), 1);
119
+
120
+ const items = getDb()
121
+ .prepare(
122
+ `SELECT * FROM messages
123
+ WHERE ${where}
124
+ ORDER BY ${orderBy}
125
+ LIMIT ? OFFSET ?`,
126
+ )
127
+ .all(...allParams, pp, offset) as unknown as MessageRow[];
128
+
129
+ return { items, page: p, perPage: pp, totalItems, totalPages };
130
+ }
131
+
132
+ export function getSessionTokenStats(
133
+ channelId: string,
134
+ threadId: string | null,
135
+ ): { totalTokensIn: number; totalTokensOut: number } {
136
+ return getDb()
137
+ .prepare(
138
+ `SELECT COALESCE(SUM(tokens_in), 0) as totalTokensIn,
139
+ COALESCE(SUM(tokens_out), 0) as totalTokensOut
140
+ FROM messages
141
+ WHERE channel_id = ? AND thread_id IS ?`,
142
+ )
143
+ .get(channelId, threadId) as unknown as { totalTokensIn: number; totalTokensOut: number };
144
+ }
145
+
146
+ export function getSessionCount(): number {
147
+ const row = getDb().prepare('SELECT COUNT(*) as count FROM sessions').get() as unknown as {
148
+ count: number;
149
+ };
150
+ return row.count;
151
+ }
152
+
153
+ export function deleteStaleSessions(ttlHours: number): number {
154
+ const stmt = getDb().prepare(
155
+ `DELETE FROM sessions WHERE last_active < datetime('now', ? || ' hours')`,
156
+ );
157
+ const result = stmt.run(`-${ttlHours}`);
158
+ return Number(result.changes);
159
+ }
160
+
161
+ export function updateSessionProviderId(uid: string, providerSessionId: string): void {
162
+ getDb()
163
+ .prepare('UPDATE sessions SET provider_session_id = ? WHERE uid = ?')
164
+ .run(providerSessionId, uid);
165
+ }
package/src/jobs.ts ADDED
@@ -0,0 +1,140 @@
1
+ /** @fileoverview Background job queue with handler registry, polling worker, and retry logic. */
2
+
3
+ import type { CreateJobOptions, JobRow, JobPriority } from './db/index.ts';
4
+ import {
5
+ createJob,
6
+ claimNextJob,
7
+ completeJob,
8
+ failJob,
9
+ failStaleJobs,
10
+ insertLog,
11
+ updateCronJobStatus,
12
+ getCronJob,
13
+ createCronRun,
14
+ completeCronRun,
15
+ } from './db/index.ts';
16
+ import { logger } from './core/logger.ts';
17
+ import { broadcastSSE } from './server/index.ts';
18
+
19
+ type JobHandler = (payload: unknown) => Promise<string | void> | string | void;
20
+
21
+ const handlers = new Map<string, JobHandler>();
22
+
23
+ const POLL_INTERVAL_MS = 1000;
24
+ const STALE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
25
+
26
+ export function registerHandler(name: string, handler: JobHandler): void {
27
+ handlers.set(name, handler);
28
+ }
29
+
30
+ export interface QueueOptions {
31
+ priority?: JobPriority;
32
+ maxAttempts?: number;
33
+ timeout?: number;
34
+ delaySeconds?: number;
35
+ cronJobUid?: string;
36
+ }
37
+
38
+ export function enqueue(name: string, payload?: unknown, options?: QueueOptions): JobRow {
39
+ const jobOptions: CreateJobOptions = {
40
+ name,
41
+ payload,
42
+ priority: options?.priority,
43
+ maxAttempts: options?.maxAttempts,
44
+ timeout: options?.timeout,
45
+ delaySeconds: options?.delaySeconds,
46
+ cronJobUid: options?.cronJobUid,
47
+ };
48
+ const job = createJob(jobOptions);
49
+ logger.debug(`enqueued job ${job.id}: ${name}`);
50
+ return job;
51
+ }
52
+
53
+ async function processJob(job: JobRow): Promise<void> {
54
+ const handler = handlers.get(job.name);
55
+ if (!handler) {
56
+ failJob(job.id, `no handler registered for "${job.name}"`);
57
+ logger.warn(`job ${job.id}: no handler for "${job.name}"`);
58
+ return;
59
+ }
60
+
61
+ const isCronRun = job.cron_job_uid != null;
62
+ const cronRun = isCronRun ? createCronRun(job.cron_job_uid!) : null;
63
+
64
+ let finalStatus = 'completed';
65
+ let resultText: string | undefined;
66
+ let errorText: string | undefined;
67
+ try {
68
+ const payload = job.payload ? (JSON.parse(job.payload) as unknown) : undefined;
69
+ const result = await handler(payload);
70
+ resultText = typeof result === 'string' ? result : undefined;
71
+ completeJob(job.id, resultText);
72
+ logger.debug(`job ${job.id} completed: ${job.name}`);
73
+ } catch (err) {
74
+ finalStatus = 'failed';
75
+ errorText = err instanceof Error ? err.message : String(err);
76
+ failJob(job.id, errorText);
77
+ logger.warn(`job ${job.id} failed (attempt ${job.attempts}/${job.max_attempts}): ${errorText}`);
78
+ insertLog('error', 'cron', errorText);
79
+ } finally {
80
+ if (cronRun != null) {
81
+ completeCronRun(
82
+ cronRun.uid,
83
+ finalStatus === 'completed' ? 'success' : 'error',
84
+ resultText,
85
+ errorText,
86
+ );
87
+ }
88
+ if (isCronRun && job.cron_job_uid != null) {
89
+ const cronJob = getCronJob(job.cron_job_uid);
90
+ if (cronJob != null) {
91
+ const newFailureCount =
92
+ finalStatus === 'failed' ? cronJob.failure_count + 1 : cronJob.failure_count;
93
+ updateCronJobStatus(job.cron_job_uid, finalStatus, newFailureCount);
94
+ }
95
+ }
96
+ const resource = isCronRun ? 'birds' : 'sessions';
97
+ const invalidatePayload: { resource: string; cronJobUid?: string } = { resource };
98
+ if (job.cron_job_uid != null) invalidatePayload.cronJobUid = job.cron_job_uid;
99
+ broadcastSSE('invalidate', invalidatePayload);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Starts the job worker loop. Polls for pending jobs, processes them one at a time.
105
+ * Checks for stale running jobs every 5 minutes.
106
+ */
107
+ export function startWorker(signal: AbortSignal): void {
108
+ let pollTimer: ReturnType<typeof setTimeout> | null = null;
109
+
110
+ const pollTick = async () => {
111
+ if (signal.aborted) return;
112
+
113
+ const job = claimNextJob();
114
+ if (job) {
115
+ await processJob(job);
116
+ if (!signal.aborted) pollTick();
117
+ return;
118
+ }
119
+
120
+ pollTimer = setTimeout(pollTick, POLL_INTERVAL_MS);
121
+ };
122
+
123
+ const staleCheck = () => {
124
+ if (signal.aborted) return;
125
+ const stale = failStaleJobs();
126
+ if (stale > 0) {
127
+ logger.info(`timed out ${stale} stale job(s)`);
128
+ }
129
+ };
130
+
131
+ staleCheck();
132
+ const staleTimer = setInterval(staleCheck, STALE_CHECK_INTERVAL_MS);
133
+
134
+ signal.addEventListener('abort', () => {
135
+ if (pollTimer) clearTimeout(pollTimer);
136
+ clearInterval(staleTimer);
137
+ });
138
+
139
+ pollTick();
140
+ }
@@ -0,0 +1,95 @@
1
+ /** @fileoverview Tests for the Claude CLI stream parser. */
2
+
3
+ import { describe, it } from 'node:test';
4
+ import { deepStrictEqual, strictEqual } from 'node:assert';
5
+ import { claude } from './claude.ts';
6
+
7
+ describe('claude parseStreamLine', () => {
8
+ it('parses system event into init', () => {
9
+ const events = claude.parseStreamLine(
10
+ '{"type":"system","session_id":"abc-123","model":"claude-sonnet-4-20250514"}',
11
+ );
12
+ strictEqual(events.length, 1);
13
+ deepStrictEqual(events[0], {
14
+ type: 'init',
15
+ sessionId: 'abc-123',
16
+ model: 'claude-sonnet-4-20250514',
17
+ });
18
+ });
19
+
20
+ it('parses assistant text content', () => {
21
+ const events = claude.parseStreamLine(
22
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"4"}]}}',
23
+ );
24
+ strictEqual(events.length, 1);
25
+ deepStrictEqual(events[0], { type: 'text_delta', delta: '4' });
26
+ });
27
+
28
+ it('parses result into completion with full metrics', () => {
29
+ const events = claude.parseStreamLine(
30
+ JSON.stringify({
31
+ type: 'result',
32
+ subtype: 'success',
33
+ result: '4',
34
+ session_id: 'abc-123',
35
+ is_error: false,
36
+ usage: {
37
+ input_tokens: 100,
38
+ output_tokens: 5,
39
+ cache_creation_input_tokens: 50,
40
+ cache_read_input_tokens: 10,
41
+ },
42
+ total_cost_usd: 0.001,
43
+ duration_ms: 1500,
44
+ num_turns: 3,
45
+ }),
46
+ );
47
+ strictEqual(events.length, 1);
48
+ const c = events[0]!;
49
+ strictEqual(c.type, 'completion');
50
+ if (c.type === 'completion') {
51
+ strictEqual(c.subtype, 'success');
52
+ strictEqual(c.tokensIn, 100);
53
+ strictEqual(c.tokensOut, 5);
54
+ strictEqual(c.cacheCreationTokens, 50);
55
+ strictEqual(c.cacheReadTokens, 10);
56
+ strictEqual(c.costUsd, 0.001);
57
+ strictEqual(c.durationMs, 1500);
58
+ strictEqual(c.numTurns, 3);
59
+ }
60
+ });
61
+
62
+ it('parses error event', () => {
63
+ const events = claude.parseStreamLine('{"type":"error","error":"something broke"}');
64
+ strictEqual(events.length, 1);
65
+ deepStrictEqual(events[0], { type: 'error', error: 'something broke' });
66
+ });
67
+
68
+ it('returns empty for blank lines', () => {
69
+ strictEqual(claude.parseStreamLine('').length, 0);
70
+ strictEqual(claude.parseStreamLine(' ').length, 0);
71
+ });
72
+
73
+ it('returns empty for non-json', () => {
74
+ strictEqual(claude.parseStreamLine('not json at all').length, 0);
75
+ });
76
+
77
+ it('ignores tool_use content blocks', () => {
78
+ const events = claude.parseStreamLine(
79
+ '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"bash","input":{}}]}}',
80
+ );
81
+ strictEqual(events.length, 0);
82
+ });
83
+
84
+ it('parses rate_limit_event', () => {
85
+ const events = claude.parseStreamLine(
86
+ '{"type":"rate_limit_event","rate_limit_info":{"status":"rate_limited","resetsAt":1700000000}}',
87
+ );
88
+ strictEqual(events.length, 1);
89
+ deepStrictEqual(events[0], {
90
+ type: 'rate_limit',
91
+ status: 'rate_limited',
92
+ resetsAt: 1700000000,
93
+ });
94
+ });
95
+ });