@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
@@ -0,0 +1,196 @@
1
+ /** @fileoverview Claude Code CLI provider: arg building and stream-json parsing. */
2
+
3
+ import type { ProviderModule, SpawnOptions, ProviderCommand } from './types.ts';
4
+ import type { StreamEvent, ToolImage } from './stream.ts';
5
+
6
+ type CompletionSubtype =
7
+ | 'success'
8
+ | 'error_max_turns'
9
+ | 'error_during_execution'
10
+ | 'error_max_budget_usd'
11
+ | 'error_max_structured_output_retries';
12
+
13
+ function buildCommand(options: SpawnOptions): ProviderCommand {
14
+ const { message, sessionId, agent, mcpConfigPath } = options;
15
+
16
+ const args: string[] = [
17
+ '-p',
18
+ message,
19
+ '--output-format',
20
+ 'stream-json',
21
+ '--model',
22
+ agent.model,
23
+ '--verbose',
24
+ '--max-turns',
25
+ String(agent.maxTurns),
26
+ ];
27
+
28
+ if (sessionId) {
29
+ args.push('--resume', sessionId);
30
+ }
31
+
32
+ if (agent.systemPrompt) {
33
+ args.push('--append-system-prompt', agent.systemPrompt);
34
+ }
35
+
36
+ if (mcpConfigPath) {
37
+ args.push('--mcp-config', mcpConfigPath);
38
+ }
39
+
40
+ if (agent.fallbackModel) {
41
+ args.push('--fallback-model', agent.fallbackModel);
42
+ }
43
+
44
+ args.push('--dangerously-skip-permissions');
45
+
46
+ const oauthToken = process.env['CLAUDE_CODE_OAUTH_TOKEN'];
47
+ const apiKey = process.env['ANTHROPIC_API_KEY'];
48
+ const env: Record<string, string> = oauthToken
49
+ ? { CLAUDE_CODE_OAUTH_TOKEN: oauthToken }
50
+ : apiKey
51
+ ? { ANTHROPIC_API_KEY: apiKey }
52
+ : {};
53
+
54
+ return { binary: 'claude', args, env };
55
+ }
56
+
57
+ /**
58
+ * Parses a single line of stream-json output into zero or more StreamEvents.
59
+ * Only extracts text, images, completion, and error events. Tool use/result
60
+ * events are internal to the agent and not surfaced to the channel layer.
61
+ */
62
+ function parseStreamLine(line: string): StreamEvent[] {
63
+ const trimmed = line.trim();
64
+ if (!trimmed || !trimmed.startsWith('{')) return [];
65
+
66
+ let parsed: Record<string, unknown>;
67
+ try {
68
+ parsed = JSON.parse(trimmed) as Record<string, unknown>;
69
+ } catch {
70
+ return [];
71
+ }
72
+
73
+ const eventType = parsed['type'] as string | undefined;
74
+ if (!eventType) return [];
75
+
76
+ switch (eventType) {
77
+ case 'system':
78
+ if (typeof parsed['session_id'] === 'string') {
79
+ return [
80
+ {
81
+ type: 'init',
82
+ sessionId: parsed['session_id'],
83
+ model: (parsed['model'] as string) ?? '',
84
+ },
85
+ ];
86
+ }
87
+ return [];
88
+
89
+ case 'assistant':
90
+ return parseAssistantContent(parsed);
91
+
92
+ case 'user':
93
+ return extractImages(parsed);
94
+
95
+ case 'result': {
96
+ const usage = parsed['usage'] as Record<string, unknown> | undefined;
97
+ const subtype = (parsed['subtype'] as CompletionSubtype | undefined) ?? 'success';
98
+ return [
99
+ {
100
+ type: 'completion',
101
+ subtype,
102
+ result: typeof parsed['result'] === 'string' ? parsed['result'] : '',
103
+ sessionId: (parsed['session_id'] as string) ?? '',
104
+ isError: (parsed['is_error'] as boolean) ?? false,
105
+ tokensIn: (usage?.['input_tokens'] as number) ?? 0,
106
+ tokensOut: (usage?.['output_tokens'] as number) ?? 0,
107
+ cacheCreationTokens: (usage?.['cache_creation_input_tokens'] as number) ?? 0,
108
+ cacheReadTokens: (usage?.['cache_read_input_tokens'] as number) ?? 0,
109
+ costUsd: (parsed['total_cost_usd'] as number) ?? 0,
110
+ durationMs: (parsed['duration_ms'] as number) ?? 0,
111
+ numTurns: (parsed['num_turns'] as number) ?? 0,
112
+ },
113
+ ];
114
+ }
115
+
116
+ case 'rate_limit_event': {
117
+ const info = parsed['rate_limit_info'] as Record<string, unknown> | undefined;
118
+ return [
119
+ {
120
+ type: 'rate_limit',
121
+ status: (info?.['status'] as string) ?? 'unknown',
122
+ resetsAt: (info?.['resetsAt'] as number) ?? 0,
123
+ },
124
+ ];
125
+ }
126
+
127
+ case 'error':
128
+ return [
129
+ {
130
+ type: 'error',
131
+ error:
132
+ typeof parsed['error'] === 'string' ? parsed['error'] : JSON.stringify(parsed['error']),
133
+ },
134
+ ];
135
+
136
+ default:
137
+ return [];
138
+ }
139
+ }
140
+
141
+ function parseAssistantContent(parsed: Record<string, unknown>): StreamEvent[] {
142
+ const msg = parsed['message'];
143
+ if (typeof msg === 'string') {
144
+ return [{ type: 'text_delta', delta: msg }];
145
+ }
146
+ if (!msg || typeof msg !== 'object') return [];
147
+
148
+ const content = (msg as Record<string, unknown>)['content'];
149
+ if (!Array.isArray(content)) return [];
150
+
151
+ const events: StreamEvent[] = [];
152
+ for (const block of content) {
153
+ if (!block || typeof block !== 'object') continue;
154
+ const b = block as Record<string, unknown>;
155
+ if (b['type'] === 'text' && typeof b['text'] === 'string') {
156
+ events.push({ type: 'text_delta', delta: b['text'] });
157
+ }
158
+ }
159
+ return events;
160
+ }
161
+
162
+ function extractImages(parsed: Record<string, unknown>): StreamEvent[] {
163
+ const msg = parsed['message'];
164
+ if (!msg || typeof msg !== 'object') return [];
165
+ const content = (msg as Record<string, unknown>)['content'];
166
+ if (!Array.isArray(content)) return [];
167
+
168
+ const images: ToolImage[] = [];
169
+ for (const block of content) {
170
+ if (!block || typeof block !== 'object') continue;
171
+ const b = block as Record<string, unknown>;
172
+ if (b['type'] !== 'tool_result') continue;
173
+
174
+ const inner = b['content'];
175
+ if (!Array.isArray(inner)) continue;
176
+ for (const item of inner) {
177
+ if (!item || typeof item !== 'object') continue;
178
+ const i = item as Record<string, unknown>;
179
+ if (i['type'] !== 'image') continue;
180
+ const source = i['source'] as Record<string, unknown> | undefined;
181
+ if (!source || source['type'] !== 'base64') continue;
182
+ images.push({
183
+ mediaType: (source['media_type'] as string) ?? 'image/png',
184
+ data: (source['data'] as string) ?? '',
185
+ });
186
+ }
187
+ }
188
+
189
+ if (images.length > 0) {
190
+ return [{ type: 'tool_images', images }];
191
+ }
192
+
193
+ return [];
194
+ }
195
+
196
+ export const claude: ProviderModule = { buildCommand, parseStreamLine };
@@ -0,0 +1,169 @@
1
+ /** @fileoverview Tests for the OpenCode CLI stream parser. */
2
+
3
+ import { describe, it } from 'node:test';
4
+ import { deepStrictEqual, strictEqual } from 'node:assert';
5
+ import { opencode } from './opencode.ts';
6
+
7
+ describe('opencode parseStreamLine', () => {
8
+ it('parses step_start into init', () => {
9
+ const events = opencode.parseStreamLine(
10
+ '{"type":"step_start","timestamp":1000000,"sessionID":"ses_abc","part":{"sessionID":"ses_abc","type":"step-start"}}',
11
+ );
12
+ strictEqual(events.length, 1);
13
+ deepStrictEqual(events[0], { type: 'init', sessionId: 'ses_abc', model: '' });
14
+ });
15
+
16
+ it('parses text event', () => {
17
+ const events = opencode.parseStreamLine(
18
+ '{"type":"text","timestamp":1001000,"sessionID":"ses_abc","part":{"sessionID":"ses_abc","type":"text","text":"hello"}}',
19
+ );
20
+ strictEqual(events.length, 1);
21
+ deepStrictEqual(events[0], { type: 'text_delta', delta: 'hello' });
22
+ });
23
+
24
+ it('ignores intermediate step_finish (tool-calls)', () => {
25
+ opencode.parseStreamLine(
26
+ '{"type":"step_start","timestamp":1000000,"sessionID":"ses_x","part":{"sessionID":"ses_x","type":"step-start"}}',
27
+ );
28
+ const events = opencode.parseStreamLine(
29
+ '{"type":"step_finish","timestamp":1002000,"sessionID":"ses_x","part":{"sessionID":"ses_x","type":"step-finish","reason":"tool-calls","cost":0.01,"tokens":{"total":500,"input":400,"output":100,"reasoning":0,"cache":{"read":50,"write":200}}}}',
30
+ );
31
+ strictEqual(events.length, 0);
32
+ });
33
+
34
+ it('accumulates metrics across multi-step sessions', () => {
35
+ // Step 1: start
36
+ opencode.parseStreamLine(
37
+ '{"type":"step_start","timestamp":1000000,"sessionID":"ses_multi","part":{"sessionID":"ses_multi","type":"step-start"}}',
38
+ );
39
+ // Step 1: finish (tool-calls, not final)
40
+ opencode.parseStreamLine(
41
+ JSON.stringify({
42
+ type: 'step_finish',
43
+ timestamp: 1002000,
44
+ sessionID: 'ses_multi',
45
+ part: {
46
+ sessionID: 'ses_multi',
47
+ type: 'step-finish',
48
+ reason: 'tool-calls',
49
+ cost: 0.003,
50
+ tokens: {
51
+ total: 500,
52
+ input: 400,
53
+ output: 100,
54
+ reasoning: 0,
55
+ cache: { read: 50, write: 200 },
56
+ },
57
+ },
58
+ }),
59
+ );
60
+ // Step 2: start
61
+ opencode.parseStreamLine(
62
+ '{"type":"step_start","timestamp":1003000,"sessionID":"ses_multi","part":{"sessionID":"ses_multi","type":"step-start"}}',
63
+ );
64
+ // Step 2: finish (stop, final)
65
+ const events = opencode.parseStreamLine(
66
+ JSON.stringify({
67
+ type: 'step_finish',
68
+ timestamp: 1005000,
69
+ sessionID: 'ses_multi',
70
+ part: {
71
+ sessionID: 'ses_multi',
72
+ type: 'step-finish',
73
+ reason: 'stop',
74
+ cost: 0.001,
75
+ tokens: {
76
+ total: 200,
77
+ input: 150,
78
+ output: 50,
79
+ reasoning: 0,
80
+ cache: { read: 30, write: 100 },
81
+ },
82
+ },
83
+ }),
84
+ );
85
+
86
+ strictEqual(events.length, 1);
87
+ const c = events[0]!;
88
+ strictEqual(c.type, 'completion');
89
+ if (c.type === 'completion') {
90
+ strictEqual(c.tokensIn, 550);
91
+ strictEqual(c.tokensOut, 150);
92
+ strictEqual(c.cacheCreationTokens, 300);
93
+ strictEqual(c.cacheReadTokens, 80);
94
+ strictEqual(c.costUsd, 0.004);
95
+ strictEqual(c.durationMs, 5000);
96
+ strictEqual(c.numTurns, 2);
97
+ strictEqual(c.sessionId, 'ses_multi');
98
+ }
99
+ });
100
+
101
+ it('handles single-step sessions', () => {
102
+ opencode.parseStreamLine(
103
+ '{"type":"step_start","timestamp":2000000,"sessionID":"ses_single","part":{"sessionID":"ses_single","type":"step-start"}}',
104
+ );
105
+ const events = opencode.parseStreamLine(
106
+ JSON.stringify({
107
+ type: 'step_finish',
108
+ timestamp: 2001000,
109
+ sessionID: 'ses_single',
110
+ part: {
111
+ sessionID: 'ses_single',
112
+ type: 'step-finish',
113
+ reason: 'stop',
114
+ cost: 0.002,
115
+ tokens: {
116
+ total: 100,
117
+ input: 80,
118
+ output: 20,
119
+ reasoning: 0,
120
+ cache: { read: 0, write: 50 },
121
+ },
122
+ },
123
+ }),
124
+ );
125
+
126
+ strictEqual(events.length, 1);
127
+ const c = events[0]!;
128
+ strictEqual(c.type, 'completion');
129
+ if (c.type === 'completion') {
130
+ strictEqual(c.durationMs, 1000);
131
+ strictEqual(c.numTurns, 1);
132
+ strictEqual(c.tokensIn, 80);
133
+ strictEqual(c.tokensOut, 20);
134
+ }
135
+ });
136
+
137
+ it('parses simple error event', () => {
138
+ const events = opencode.parseStreamLine('{"type":"error","message":"auth failed"}');
139
+ strictEqual(events.length, 1);
140
+ deepStrictEqual(events[0], { type: 'error', error: 'auth failed' });
141
+ });
142
+
143
+ it('extracts message from nested API error', () => {
144
+ const events = opencode.parseStreamLine(
145
+ JSON.stringify({
146
+ type: 'error',
147
+ error: {
148
+ name: 'APIError',
149
+ data: { message: 'Your credit balance is too low', statusCode: 400 },
150
+ },
151
+ }),
152
+ );
153
+ strictEqual(events.length, 1);
154
+ deepStrictEqual(events[0], { type: 'error', error: 'Your credit balance is too low' });
155
+ });
156
+
157
+ it('falls back to error name when no message', () => {
158
+ const events = opencode.parseStreamLine(
159
+ JSON.stringify({ type: 'error', error: { name: 'UnknownError', data: {} } }),
160
+ );
161
+ strictEqual(events.length, 1);
162
+ deepStrictEqual(events[0], { type: 'error', error: 'UnknownError' });
163
+ });
164
+
165
+ it('returns empty for blank lines and non-json', () => {
166
+ strictEqual(opencode.parseStreamLine('').length, 0);
167
+ strictEqual(opencode.parseStreamLine('garbage').length, 0);
168
+ });
169
+ });
@@ -0,0 +1,248 @@
1
+ /** @fileoverview OpenCode CLI provider: arg building, workspace setup, and JSON stream parsing. */
2
+
3
+ import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import type { ProviderModule, SpawnOptions, ProviderCommand } from './types.ts';
6
+ import type { StreamEvent } from './stream.ts';
7
+ import { logger } from '../core/logger.ts';
8
+
9
+ const WORKSPACE_DIR = resolve('.browserbird', 'opencode');
10
+
11
+ /**
12
+ * Translates a Claude-format MCP config into an opencode-format config.
13
+ *
14
+ * Claude format: { mcpServers: { name: { type: "sse", url: "..." } } }
15
+ * OpenCode format: { mcp: { name: { type: "remote", url: "..." } } }
16
+ */
17
+ function translateMcpConfig(
18
+ claudeConfig: Record<string, unknown>,
19
+ ): Record<string, Record<string, unknown>> {
20
+ const servers = (claudeConfig['mcpServers'] ?? {}) as Record<string, Record<string, unknown>>;
21
+ const result: Record<string, Record<string, unknown>> = {};
22
+
23
+ for (const [name, server] of Object.entries(servers)) {
24
+ const serverType = server['type'] as string | undefined;
25
+ const url = server['url'] as string | undefined;
26
+ const command = server['command'] as string | undefined;
27
+ const args = server['args'] as string[] | undefined;
28
+
29
+ if (serverType === 'sse' || serverType === 'streamable-http') {
30
+ if (url) {
31
+ result[name] = { type: 'remote', url };
32
+ }
33
+ } else if (serverType === 'stdio') {
34
+ if (command) {
35
+ const cmd = args ? [command, ...args] : [command];
36
+ const entry: Record<string, unknown> = { type: 'local', command: cmd };
37
+ const env = server['env'] as Record<string, string> | undefined;
38
+ if (env) entry['environment'] = env;
39
+ result[name] = entry;
40
+ }
41
+ }
42
+ }
43
+
44
+ return result;
45
+ }
46
+
47
+ /**
48
+ * Ensures the opencode workspace directory exists with the right config files.
49
+ * Writes opencode.json (MCP servers) and .opencode/agent/browserbird.md (system prompt).
50
+ */
51
+ function ensureWorkspace(mcpConfigPath?: string, systemPrompt?: string): void {
52
+ mkdirSync(resolve(WORKSPACE_DIR, '.opencode', 'agent'), { recursive: true });
53
+
54
+ const config: Record<string, unknown> = {};
55
+
56
+ if (mcpConfigPath) {
57
+ try {
58
+ const raw = readFileSync(resolve(mcpConfigPath), 'utf-8');
59
+ const claudeConfig = JSON.parse(raw) as Record<string, unknown>;
60
+ const mcp = translateMcpConfig(claudeConfig);
61
+ if (Object.keys(mcp).length > 0) {
62
+ config['mcp'] = mcp;
63
+ }
64
+ } catch (err) {
65
+ logger.warn(
66
+ `opencode: failed to read MCP config at ${mcpConfigPath}: ${err instanceof Error ? err.message : String(err)}`,
67
+ );
68
+ }
69
+ }
70
+
71
+ writeFileSync(resolve(WORKSPACE_DIR, 'opencode.json'), JSON.stringify(config, null, 2) + '\n');
72
+
73
+ if (systemPrompt) {
74
+ const agentMd = `---\nmode: primary\n---\n\n${systemPrompt}\n`;
75
+ writeFileSync(resolve(WORKSPACE_DIR, '.opencode', 'agent', 'browserbird.md'), agentMd);
76
+ }
77
+
78
+ const agentsMd = resolve('AGENTS.md');
79
+ if (existsSync(agentsMd)) {
80
+ copyFileSync(agentsMd, resolve(WORKSPACE_DIR, 'AGENTS.md'));
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Builds the `opencode run` command for a given agent config.
86
+ *
87
+ * @remarks `fallbackModel` is not yet supported by opencode.
88
+ * @see https://github.com/anomalyco/opencode/issues/7602
89
+ */
90
+ function buildCommand(options: SpawnOptions): ProviderCommand {
91
+ const { message, sessionId, agent, mcpConfigPath } = options;
92
+
93
+ ensureWorkspace(mcpConfigPath, agent.systemPrompt);
94
+
95
+ const args: string[] = ['run', '--format', 'json', '-m', agent.model];
96
+
97
+ if (agent.systemPrompt) {
98
+ args.push('--agent', 'browserbird');
99
+ }
100
+
101
+ if (sessionId) {
102
+ args.push('--session', sessionId);
103
+ }
104
+
105
+ args.push(message);
106
+
107
+ const env: Record<string, string> = {};
108
+ const apiKey = process.env['ANTHROPIC_API_KEY'];
109
+ if (apiKey) env['ANTHROPIC_API_KEY'] = apiKey;
110
+ const openRouterKey = process.env['OPENROUTER_API_KEY'];
111
+ if (openRouterKey) env['OPENROUTER_API_KEY'] = openRouterKey;
112
+ const openAiKey = process.env['OPENAI_API_KEY'];
113
+ if (openAiKey) env['OPENAI_API_KEY'] = openAiKey;
114
+ const geminiKey = process.env['GEMINI_API_KEY'];
115
+ if (geminiKey) env['GEMINI_API_KEY'] = geminiKey;
116
+
117
+ return { binary: 'opencode', args, cwd: WORKSPACE_DIR, env };
118
+ }
119
+
120
+ interface MetricAccumulator {
121
+ startTimestamp: number;
122
+ stepCount: number;
123
+ tokensIn: number;
124
+ tokensOut: number;
125
+ cacheWrite: number;
126
+ cacheRead: number;
127
+ cost: number;
128
+ }
129
+
130
+ /** Per-session metric accumulators. Concurrent sessions each get their own entry. */
131
+ const accumulators = new Map<string, MetricAccumulator>();
132
+
133
+ function accumulateStep(sessionId: string, part: Record<string, unknown>): void {
134
+ const acc = accumulators.get(sessionId)!;
135
+ acc.stepCount++;
136
+ const tokens = part['tokens'] as Record<string, unknown> | undefined;
137
+ const cache = tokens?.['cache'] as Record<string, unknown> | undefined;
138
+ acc.tokensIn += (tokens?.['input'] as number) ?? 0;
139
+ acc.tokensOut += (tokens?.['output'] as number) ?? 0;
140
+ acc.cacheWrite += (cache?.['write'] as number) ?? 0;
141
+ acc.cacheRead += (cache?.['read'] as number) ?? 0;
142
+ acc.cost += (part['cost'] as number) ?? 0;
143
+ }
144
+
145
+ /**
146
+ * Parses a single line of opencode JSON output into zero or more StreamEvents.
147
+ *
148
+ * OpenCode emits these event types:
149
+ * step_start -> init (first one carries sessionID)
150
+ * text -> text_delta
151
+ * tool_use -> ignored (internal to the agent)
152
+ * step_finish -> accumulates tokens/cost; final one (reason "stop") emits completion
153
+ * error -> error (connection/auth failures)
154
+ */
155
+ function parseStreamLine(line: string): StreamEvent[] {
156
+ const trimmed = line.trim();
157
+ if (!trimmed || !trimmed.startsWith('{')) return [];
158
+
159
+ let parsed: Record<string, unknown>;
160
+ try {
161
+ parsed = JSON.parse(trimmed) as Record<string, unknown>;
162
+ } catch {
163
+ return [];
164
+ }
165
+
166
+ const eventType = parsed['type'] as string | undefined;
167
+ if (!eventType) return [];
168
+ const part = parsed['part'] as Record<string, unknown> | undefined;
169
+ const timestamp = (parsed['timestamp'] as number) ?? 0;
170
+
171
+ switch (eventType) {
172
+ case 'step_start':
173
+ if (part && typeof part['sessionID'] === 'string') {
174
+ const sid = part['sessionID'];
175
+ if (!accumulators.has(sid)) {
176
+ accumulators.set(sid, {
177
+ startTimestamp: timestamp,
178
+ stepCount: 0,
179
+ tokensIn: 0,
180
+ tokensOut: 0,
181
+ cacheWrite: 0,
182
+ cacheRead: 0,
183
+ cost: 0,
184
+ });
185
+ }
186
+ return [
187
+ {
188
+ type: 'init',
189
+ sessionId: sid,
190
+ model: '',
191
+ },
192
+ ];
193
+ }
194
+ return [];
195
+
196
+ case 'text':
197
+ if (part && typeof part['text'] === 'string') {
198
+ return [{ type: 'text_delta', delta: part['text'] }];
199
+ }
200
+ return [];
201
+
202
+ case 'step_finish': {
203
+ if (!part) return [];
204
+ const sid = (part['sessionID'] as string) ?? '';
205
+ accumulateStep(sid, part);
206
+
207
+ const reason = part['reason'] as string | undefined;
208
+ if (reason !== 'stop') return [];
209
+
210
+ const acc = accumulators.get(sid)!;
211
+
212
+ const durationMs = timestamp > acc.startTimestamp ? timestamp - acc.startTimestamp : 0;
213
+ const completion: StreamEvent = {
214
+ type: 'completion',
215
+ subtype: 'success',
216
+ result: '',
217
+ sessionId: sid,
218
+ isError: false,
219
+ tokensIn: acc.tokensIn,
220
+ tokensOut: acc.tokensOut,
221
+ cacheCreationTokens: acc.cacheWrite,
222
+ cacheReadTokens: acc.cacheRead,
223
+ costUsd: acc.cost,
224
+ durationMs,
225
+ numTurns: acc.stepCount,
226
+ };
227
+
228
+ accumulators.delete(sid);
229
+ return [completion];
230
+ }
231
+
232
+ case 'error': {
233
+ const err = parsed['error'] as Record<string, unknown> | undefined;
234
+ const data = err?.['data'] as Record<string, unknown> | undefined;
235
+ const msg =
236
+ (typeof data?.['message'] === 'string' && data['message']) ||
237
+ (typeof parsed['message'] === 'string' && parsed['message']) ||
238
+ (typeof err?.['name'] === 'string' && err['name']) ||
239
+ JSON.stringify(parsed);
240
+ return [{ type: 'error', error: msg }];
241
+ }
242
+
243
+ default:
244
+ return [];
245
+ }
246
+ }
247
+
248
+ export const opencode: ProviderModule = { buildCommand, parseStreamLine };
@@ -0,0 +1,65 @@
1
+ /** @fileoverview Session router: maps Slack threads to CLI sessions. */
2
+
3
+ import type { AgentConfig, Config } from '../core/types.ts';
4
+ import type { SessionRow } from '../db/index.ts';
5
+ import { logger } from '../core/logger.ts';
6
+ import * as db from '../db/index.ts';
7
+
8
+ /**
9
+ * Matches an incoming message to the correct agent based on channel config.
10
+ * Agents are checked in order; first match wins.
11
+ * A wildcard `"*"` in the agent's channels array matches everything.
12
+ */
13
+ export function matchAgent(channelId: string, agents: AgentConfig[]): AgentConfig | undefined {
14
+ for (const agent of agents) {
15
+ for (const pattern of agent.channels) {
16
+ if (pattern === '*' || pattern === channelId) {
17
+ return agent;
18
+ }
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ /**
25
+ * Looks up or creates a session for the given Slack thread.
26
+ * Returns the session row and whether it was newly created.
27
+ */
28
+ export function resolveSession(
29
+ channelId: string,
30
+ threadTs: string | null,
31
+ config: Config,
32
+ ): { session: SessionRow; agent: AgentConfig; isNew: boolean } | null {
33
+ const agent = matchAgent(channelId, config.agents);
34
+ if (!agent) {
35
+ logger.warn(`no agent matched for channel ${channelId}`);
36
+ return null;
37
+ }
38
+
39
+ const existing = db.findSession(channelId, threadTs);
40
+ if (existing) {
41
+ const ageHours =
42
+ (Date.now() - new Date(existing.last_active + 'Z').getTime()) / (1000 * 60 * 60);
43
+
44
+ if (ageHours > config.sessions.ttlHours) {
45
+ logger.info(`session ${existing.uid} expired (${ageHours.toFixed(1)}h old), starting fresh`);
46
+ return { session: existing, agent, isNew: true };
47
+ }
48
+
49
+ return { session: existing, agent, isNew: false };
50
+ }
51
+
52
+ const session = db.createSession(channelId, threadTs, agent.id, '');
53
+ logger.info(
54
+ `created session ${session.uid} for channel=${channelId} thread=${threadTs ?? 'none'} agent=${agent.id}`,
55
+ );
56
+ return { session, agent, isNew: true };
57
+ }
58
+
59
+ export function expireStaleSessions(ttlHours: number): number {
60
+ const deleted = db.deleteStaleSessions(ttlHours);
61
+ if (deleted > 0) {
62
+ logger.info(`expired ${deleted} stale session(s)`);
63
+ }
64
+ return deleted;
65
+ }