@openboard/start 1.0.20

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 (27) hide show
  1. package/README.md +113 -0
  2. package/bin/openboard.js +93 -0
  3. package/package.json +33 -0
  4. package/packages/client/dist/assets/index-B5k_YB6Y.css +1 -0
  5. package/packages/client/dist/assets/index-B5qL8ybM.js +173 -0
  6. package/packages/client/dist/index.html +15 -0
  7. package/packages/server/dist/agents/agent-queue.js +175 -0
  8. package/packages/server/dist/agents/agent-runner.js +18 -0
  9. package/packages/server/dist/agents/agent.interface.js +1 -0
  10. package/packages/server/dist/agents/codereview.agent.js +127 -0
  11. package/packages/server/dist/agents/dummy.agent.js +49 -0
  12. package/packages/server/dist/agents/opencode.agent.js +219 -0
  13. package/packages/server/dist/agents/opencode.events.js +364 -0
  14. package/packages/server/dist/db/database.js +170 -0
  15. package/packages/server/dist/gh-worker.js +82 -0
  16. package/packages/server/dist/index.js +79 -0
  17. package/packages/server/dist/repositories/board.repository.js +56 -0
  18. package/packages/server/dist/repositories/column-config.repository.js +30 -0
  19. package/packages/server/dist/repositories/column.repository.js +36 -0
  20. package/packages/server/dist/repositories/comment.repository.js +35 -0
  21. package/packages/server/dist/repositories/ticket.repository.js +171 -0
  22. package/packages/server/dist/routes/boards.router.js +33 -0
  23. package/packages/server/dist/routes/column-config.router.js +44 -0
  24. package/packages/server/dist/routes/columns.router.js +45 -0
  25. package/packages/server/dist/routes/tickets.router.js +88 -0
  26. package/packages/server/dist/sse.js +43 -0
  27. package/packages/server/dist/types.js +2 -0
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>OpenBoard</title>
7
+ <meta name="description" content="A local project management board — create boards, columns, and tickets." />
8
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
9
+ <script type="module" crossorigin src="/assets/index-B5qL8ybM.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-B5k_YB6Y.css">
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ </body>
15
+ </html>
@@ -0,0 +1,175 @@
1
+ import { ticketRepository } from '../repositories/ticket.repository.js';
2
+ import { columnConfigRepository } from '../repositories/column-config.repository.js';
3
+ // Agents are lazy-loaded inside dispatchAgent() to avoid circular ESM imports
4
+ // (agent files import opencode.events.ts which imports agentQueue from here)
5
+ async function resolveAgentClass(agentType) {
6
+ switch (agentType) {
7
+ case 'dummy': return (await import('./dummy.agent.js')).DummyAgent;
8
+ case 'opencode': return (await import('./opencode.agent.js')).OpencodeAgent;
9
+ case 'code_review': return (await import('./codereview.agent.js')).CodeReviewAgent;
10
+ default: return undefined;
11
+ }
12
+ }
13
+ const PRIORITY_WEIGHTS = {
14
+ urgent: 4,
15
+ high: 3,
16
+ medium: 2,
17
+ low: 1,
18
+ };
19
+ class AgentQueueManager {
20
+ // Ticket IDs currently running (to prevent same ticket starting twice)
21
+ runningTickets = new Set();
22
+ // Map to prevent concurrent evaluation of the same column
23
+ evaluatingColumns = new Set();
24
+ // Tickets explicitly forced to re-run even if their last session is 'done'
25
+ forcedTickets = new Set();
26
+ /**
27
+ * Enqueue a ticket for processing.
28
+ * In the new design, we just evaluate the column it belongs to.
29
+ */
30
+ async enqueue(ticketId, force = false) {
31
+ const ticket = ticketRepository.findById(ticketId);
32
+ if (ticket) {
33
+ if (force) {
34
+ // Mark this ticket as forced so evaluateColumnQueue bypasses the 'done' skip
35
+ this.forcedTickets.add(ticketId);
36
+ }
37
+ this.evaluateColumnQueue(ticket.column_id);
38
+ }
39
+ }
40
+ /**
41
+ * Trigger a global evaluation (e.g. on startup or general pings).
42
+ * Ideally, we use evaluateColumnQueue for specific events.
43
+ */
44
+ async ping() {
45
+ // Iterate all active columns and evaluate them
46
+ // For simplicity now, we'll just not do a global ping unless needed,
47
+ // as the system is reactive per-column now.
48
+ // But to keep API stable, let's look up all tickets and find unique columns config'd for agents.
49
+ const dbModule = await import('../db/database.js');
50
+ const db = dbModule.getDb();
51
+ const activeCols = db.prepare("SELECT column_id FROM column_configs WHERE agent_type != 'none'").all();
52
+ for (const col of activeCols) {
53
+ this.evaluateColumnQueue(col.column_id);
54
+ }
55
+ }
56
+ /**
57
+ * Evaluate a specific column and dispatch agents if slots are available.
58
+ */
59
+ async evaluateColumnQueue(columnId) {
60
+ if (this.evaluatingColumns.has(columnId))
61
+ return;
62
+ this.evaluatingColumns.add(columnId);
63
+ try {
64
+ const config = columnConfigRepository.findByColumnId(columnId);
65
+ if (!config || config.agent_type === 'none')
66
+ return;
67
+ const maxAgents = config.max_agents ?? 1;
68
+ const tickets = ticketRepository.findByColumnId(columnId);
69
+ // Find how many currently running active sessions this column has
70
+ let activeCount = 0;
71
+ const eligibleTickets = [];
72
+ for (const ticket of tickets) {
73
+ // Skip if already running in memory
74
+ if (this.runningTickets.has(ticket.id)) {
75
+ activeCount++;
76
+ continue;
77
+ }
78
+ // Look at the LAST session in the ENTIRE history array.
79
+ // - If no sessions: ticket is fresh → eligible
80
+ // - If last session is for a DIFFERENT column: ticket just arrived here → eligible
81
+ // - If last session is for THIS column:
82
+ // processing/needs_approval → already running
83
+ // done → finished here naturally, skip
84
+ // blocked → skip unless forced (retry path)
85
+ const lastSession = ticket.agent_sessions.length > 0
86
+ ? ticket.agent_sessions[ticket.agent_sessions.length - 1]
87
+ : null;
88
+ if (!lastSession) {
89
+ // Fresh ticket, no history
90
+ eligibleTickets.push(ticket);
91
+ }
92
+ else if (lastSession.column_id !== columnId) {
93
+ // Ticket just arrived from another column (on_finish or on_reject move)
94
+ eligibleTickets.push(ticket);
95
+ }
96
+ else {
97
+ // Last session is for this column
98
+ if (lastSession.status === 'processing' || lastSession.status === 'needs_approval') {
99
+ activeCount++;
100
+ }
101
+ else if (lastSession.status === 'done') {
102
+ // Finished here naturally; don't re-run unless forced
103
+ if (this.forcedTickets.has(ticket.id)) {
104
+ eligibleTickets.push(ticket);
105
+ }
106
+ // else skip
107
+ }
108
+ else if (lastSession.status === 'blocked') {
109
+ // Blocked; skip unless the user explicitly retried (force)
110
+ if (this.forcedTickets.has(ticket.id)) {
111
+ eligibleTickets.push(ticket);
112
+ }
113
+ // else skip
114
+ }
115
+ }
116
+ }
117
+ if (activeCount >= maxAgents) {
118
+ // At concurrency limit.
119
+ return;
120
+ }
121
+ // Sort eligible: highest priority first, then oldest created_at first
122
+ eligibleTickets.sort((a, b) => {
123
+ const weightA = PRIORITY_WEIGHTS[a.priority] ?? 2;
124
+ const weightB = PRIORITY_WEIGHTS[b.priority] ?? 2;
125
+ if (weightA !== weightB) {
126
+ return weightB - weightA; // Descending priority
127
+ }
128
+ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); // Ascending date
129
+ });
130
+ // Dispatch agents for top eligible tickets up to the remaining slots
131
+ const slotsAvailable = maxAgents - activeCount;
132
+ const toDispatch = eligibleTickets.slice(0, slotsAvailable);
133
+ for (const ticket of toDispatch) {
134
+ this.forcedTickets.delete(ticket.id); // Clear forced flag before dispatching
135
+ this.runningTickets.add(ticket.id);
136
+ this.dispatchAgent(ticket, config).catch(err => {
137
+ console.error(`[agent-queue] Failed to dispatch agent for ticket ${ticket.id}`, err);
138
+ }).finally(() => {
139
+ this.runningTickets.delete(ticket.id);
140
+ // Re-evaluate when slot frees up
141
+ this.evaluateColumnQueue(columnId);
142
+ });
143
+ }
144
+ }
145
+ finally {
146
+ this.evaluatingColumns.delete(columnId);
147
+ }
148
+ }
149
+ async dispatchAgent(ticket, config) {
150
+ console.log(`[agent-queue] Dispatching agent ${config.agent_type} for ticket ${ticket.id} in column ${ticket.column_id} (Priority: ${ticket.priority})`);
151
+ // Agents own setting their own 'processing' status at the start of run()
152
+ const AgentClass = await resolveAgentClass(config.agent_type);
153
+ if (!AgentClass) {
154
+ console.warn(`[agent-queue] Unknown agent type: ${config.agent_type}`);
155
+ return;
156
+ }
157
+ const agent = new AgentClass();
158
+ try {
159
+ await agent.run(ticket, config);
160
+ }
161
+ catch (err) {
162
+ console.error(`[agent-queue] Error executing agent ${config.agent_type} on ticket ${ticket.id}:`, err);
163
+ // Mark as blocked on execution failure safely
164
+ ticketRepository.updateAgentSession(ticket.id, {
165
+ column_id: ticket.column_id,
166
+ agent_type: config.agent_type,
167
+ status: 'blocked',
168
+ error_message: err instanceof Error ? err.message : String(err)
169
+ });
170
+ // Optionally, we could set agent_status to 'error' or 'blocked' here if agent.run didn't manage to handle the error properly.
171
+ // Oh wait, we already did via updateAgentSession! Great!
172
+ }
173
+ }
174
+ }
175
+ export const agentQueue = new AgentQueueManager();
@@ -0,0 +1,18 @@
1
+ import { columnConfigRepository } from '../repositories/column-config.repository.js';
2
+ import { agentQueue } from './agent-queue.js';
3
+ /**
4
+ * Checks whether the column the ticket just arrived in has an agent configured,
5
+ * and if so, spawns the agent to run asynchronously (fire-and-forget).
6
+ */
7
+ export function triggerAgent(ticket, force = false) {
8
+ const config = columnConfigRepository.findByColumnId(ticket.column_id);
9
+ if (!config || config.agent_type === 'none') {
10
+ // Even if the new column doesn't have an agent, this ticket might have moved
11
+ // OUT of a column that DOES have an agent, freeing up a concurrency slot.
12
+ // Therefore we must ping the queue to evaluate.
13
+ agentQueue.ping();
14
+ return;
15
+ }
16
+ console.log(`[agent-runner] Enqueuing ticket ${ticket.id} for agent ${config.agent_type} (Priority: ${ticket.priority})`);
17
+ agentQueue.enqueue(ticket.id, force);
18
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
1
+ import { createOpencodeClient } from '@opencode-ai/sdk';
2
+ import { ticketRepository } from '../repositories/ticket.repository.js';
3
+ import { commentRepository } from '../repositories/comment.repository.js';
4
+ import { setupOpencodeEventListener } from './opencode.events.js';
5
+ import { execFile, exec } from 'child_process';
6
+ import { promisify } from 'util';
7
+ const execAsync = promisify(exec);
8
+ const execFileAsync = promisify(execFile);
9
+ // Helper function to execute commands robustly on Windows
10
+ async function runCmd(cmd, args, cwd) {
11
+ console.log(`[codereview-agent] Running: ${cmd} ${args.join(' ')} in cwd: ${cwd}`);
12
+ try {
13
+ return await execFileAsync(cmd, args, { cwd });
14
+ }
15
+ catch (e) {
16
+ if (e.code === 'ENOENT') {
17
+ console.log(`[codereview-agent] ENOENT finding binary. Trying fallback exec...`);
18
+ return await execAsync(`${cmd} ${args.join(' ')}`, { cwd });
19
+ }
20
+ throw e;
21
+ }
22
+ }
23
+ const opencodePort = process.env.OPENCODE_PORT || 4096;
24
+ const opencodeClient = createOpencodeClient({ baseUrl: `http://127.0.0.1:${opencodePort}` });
25
+ const activeSessions = {};
26
+ export class CodeReviewAgent {
27
+ async run(ticket, config) {
28
+ console.log(`[codereview-agent] Starting session for ticket ${ticket.id}...`);
29
+ // Fetch the absolute latest ticket from the DB to ensure we have the latest session history (with PR URLs)
30
+ const latestTicket = ticketRepository.findById(ticket.id) || ticket;
31
+ // Find the PR URL from a previous session
32
+ const prUrlSession = [...(latestTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
33
+ const prUrl = prUrlSession?.pr_url;
34
+ if (!prUrl) {
35
+ console.error(`[codereview-agent] No PR URL found for ticket ${ticket.id}`);
36
+ ticketRepository.updateAgentSession(ticket.id, {
37
+ column_id: ticket.column_id,
38
+ agent_type: 'code_review',
39
+ status: 'blocked',
40
+ error_message: 'No PR found on this ticket to review. Make sure an OpenCode agent runs first.'
41
+ });
42
+ commentRepository.create({
43
+ ticketId: ticket.id,
44
+ author: 'System',
45
+ content: `❌ **Code Review Failed**\n\nCould not find a Pull Request to review. Did the developer agent create one?`
46
+ });
47
+ return;
48
+ }
49
+ ticketRepository.updateAgentSession(ticket.id, {
50
+ column_id: ticket.column_id,
51
+ agent_type: 'code_review',
52
+ status: 'processing',
53
+ port: Number(opencodePort)
54
+ });
55
+ if (activeSessions[ticket.id]) {
56
+ try {
57
+ await opencodeClient.session.abort({ path: { id: activeSessions[ticket.id] } });
58
+ }
59
+ catch (e) {
60
+ console.error(`[codereview-agent] Error aborting previous session:`, e);
61
+ }
62
+ delete activeSessions[ticket.id];
63
+ }
64
+ // Resolve workspace path — code review runs in the main workspace (no new worktree needed).
65
+ let workspacePath = process.cwd();
66
+ try {
67
+ const session = await opencodeClient.session.create({
68
+ body: { title: `Code Review for Ticket: ${ticket.title}` },
69
+ query: { directory: workspacePath }
70
+ });
71
+ if (!session.data)
72
+ throw new Error("Failed to create OpenCode session");
73
+ const sessionID = session.data.id;
74
+ activeSessions[ticket.id] = sessionID;
75
+ const encodedPath = Buffer.from(workspacePath).toString('base64url');
76
+ const agentUrl = `http://127.0.0.1:${opencodePort}/${encodedPath}/session/${sessionID}`;
77
+ ticketRepository.updateAgentSession(ticket.id, {
78
+ column_id: ticket.column_id,
79
+ agent_type: 'code_review',
80
+ status: 'processing',
81
+ port: Number(opencodePort),
82
+ url: agentUrl
83
+ });
84
+ const events = await opencodeClient.event.subscribe();
85
+ // Setup the event listener for code_review agent type.
86
+ // worktreePath = workspacePath (main workspace) — used for gh commands inside the event handler.
87
+ setupOpencodeEventListener(events, opencodeClient, sessionID, ticket, agentUrl, config, activeSessions, workspacePath, // worktreePath — main workspace for gh commands
88
+ workspacePath, // originalWorkspacePath — same here, no separate worktree
89
+ '', // branchName — no branch created for code review
90
+ 'code_review');
91
+ // Fetch GH token so the LLM environment can execute `gh` commands
92
+ let ghTokenEnv = '';
93
+ try {
94
+ const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], workspacePath);
95
+ if (ghToken.trim()) {
96
+ ghTokenEnv = `export GH_TOKEN=${ghToken.trim()}; `;
97
+ }
98
+ }
99
+ catch (authErr) {
100
+ console.warn(`[codereview-agent] Could not fetch GH token:`, authErr);
101
+ }
102
+ const promptRes = await opencodeClient.session.promptAsync({
103
+ path: { id: sessionID },
104
+ body: {
105
+ parts: [{
106
+ type: "text",
107
+ text: `# TASK: Code Review for "${ticket.title}"\n\nThe Pull Request to review is located at: ${prUrl}\n\n## Instructions\n1. Download the diff using \`${ghTokenEnv}gh pr diff ${prUrl}\`.\n2. Analyze the changes for bugs, security issues, best practices, and edge cases.\n3. If the code looks good, leave a comment using \`${ghTokenEnv}gh pr comment ${prUrl} -b "LGTM! [APPROVED]"\`.\n4. If changes are needed, explicitly request changes using \`${ghTokenEnv}gh pr comment ${prUrl} -b "<reason> [CHANGES_REQUESTED]"\`.\n5. Summarize your review directly in this chat.`
108
+ }]
109
+ }
110
+ });
111
+ if (promptRes.error) {
112
+ throw new Error(`OpenCode session error: ${JSON.stringify(promptRes.error)}`);
113
+ }
114
+ console.log(`[codereview-agent] Code review agent dispatched for ticket ${ticket.id}. Waiting for completion in background.`);
115
+ }
116
+ catch (e) {
117
+ console.error(`[codereview-agent] Failed to initialize task over SDK: ${e.message}`);
118
+ delete activeSessions[ticket.id];
119
+ ticketRepository.updateAgentSession(ticket.id, {
120
+ column_id: ticket.column_id,
121
+ agent_type: 'code_review',
122
+ status: 'blocked',
123
+ error_message: e.message
124
+ });
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,49 @@
1
+ import { commentRepository } from '../repositories/comment.repository.js';
2
+ import { ticketRepository } from '../repositories/ticket.repository.js';
3
+ import { sseManager } from '../sse.js';
4
+ const DELAY_MS = 10_000;
5
+ /**
6
+ * DummyAgent — simulates work by waiting 10 seconds, then:
7
+ * 1. Posts a comment "I finished the task" on the ticket.
8
+ * 2. Emits a `comment:added` SSE event.
9
+ * 3. If `on_finish_column_id` is configured, moves the ticket there
10
+ * and emits a `ticket:moved` SSE event.
11
+ */
12
+ export class DummyAgent {
13
+ async run(ticket, config) {
14
+ console.log(`[dummy-agent] Starting work for ticket ${ticket.id} (will wait 10s)...`);
15
+ await new Promise(resolve => setTimeout(resolve, DELAY_MS));
16
+ // 1 — Post completion comment
17
+ const comment = commentRepository.create({
18
+ ticketId: ticket.id,
19
+ author: 'dummy-agent',
20
+ content: 'I finished the task',
21
+ });
22
+ console.log(`[dummy-agent] Posted comment for ticket ${ticket.id}`);
23
+ sseManager.emit(ticket.board_id, 'comment:added', {
24
+ ticketId: ticket.id,
25
+ comment: comment
26
+ });
27
+ // 2 — Set status to done
28
+ const updated = ticketRepository.updateAgentSession(ticket.id, {
29
+ column_id: ticket.column_id,
30
+ agent_type: 'dummy',
31
+ status: 'done'
32
+ });
33
+ if (updated) {
34
+ sseManager.emit(ticket.board_id, 'ticket:updated', updated);
35
+ }
36
+ // 3 — Move ticket to the configured destination column (if set)
37
+ if (config.on_finish_column_id) {
38
+ console.log(`[dummy-agent] Moving ticket ${ticket.id} to column ${config.on_finish_column_id}`);
39
+ const moved = ticketRepository.move(ticket.id, config.on_finish_column_id, 0);
40
+ if (moved) {
41
+ sseManager.emit(ticket.board_id, 'ticket:moved', moved);
42
+ // Also trigger agent for the NEW column if one is configured there
43
+ const { triggerAgent } = await import('./agent-runner.js');
44
+ triggerAgent(moved);
45
+ }
46
+ }
47
+ console.log(`[dummy-agent] Finished work for ticket ${ticket.id}`);
48
+ }
49
+ }
@@ -0,0 +1,219 @@
1
+ import { createOpencodeClient } from '@opencode-ai/sdk';
2
+ import { ticketRepository } from '../repositories/ticket.repository.js';
3
+ import { commentRepository } from '../repositories/comment.repository.js';
4
+ import { setupOpencodeEventListener } from './opencode.events.js';
5
+ import { execFile, exec } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import fs from 'fs';
10
+ const execAsync = promisify(exec);
11
+ const execFileAsync = promisify(execFile);
12
+ // Cache the gh token so we only fetch it once per server process
13
+ let cachedGhToken = null;
14
+ async function getGhToken(cwd) {
15
+ if (cachedGhToken !== null)
16
+ return cachedGhToken;
17
+ try {
18
+ const { stdout } = await execFileAsync('gh', ['auth', 'token'], { cwd });
19
+ cachedGhToken = stdout.trim() || null;
20
+ }
21
+ catch {
22
+ cachedGhToken = null;
23
+ }
24
+ return cachedGhToken;
25
+ }
26
+ // Helper function to execute commands robustly on Windows.
27
+ // Automatically injects GH_TOKEN for any `gh` subcommand.
28
+ async function runCmd(cmd, args, cwd) {
29
+ console.log(`[opencode-agent] Running: ${cmd} ${args.join(' ')} in cwd: ${cwd}`);
30
+ let extraEnv = {};
31
+ if (cmd === 'gh') {
32
+ const token = await getGhToken(cwd);
33
+ if (token)
34
+ extraEnv['GH_TOKEN'] = token;
35
+ }
36
+ const env = Object.keys(extraEnv).length > 0 ? { ...process.env, ...extraEnv } : undefined;
37
+ try {
38
+ return await execFileAsync(cmd, args, { cwd, ...(env && { env }) });
39
+ }
40
+ catch (e) {
41
+ if (e.code === 'ENOENT') {
42
+ console.log(`[opencode-agent] ENOENT finding binary. Trying fallback exec...`);
43
+ const envPrefix = extraEnv['GH_TOKEN'] ? (process.platform === 'win32' ? `set GH_TOKEN=${extraEnv['GH_TOKEN']}&& ` : `GH_TOKEN=${extraEnv['GH_TOKEN']} `) : '';
44
+ return await execAsync(`${envPrefix}${cmd} ${args.join(' ')}`, { cwd });
45
+ }
46
+ throw e;
47
+ }
48
+ }
49
+ // Central OpenCode client connecting to the user's running OpenCode server
50
+ const opencodePort = process.env.OPENCODE_PORT || 4096;
51
+ const opencodeClient = createOpencodeClient({ baseUrl: `http://127.0.0.1:${opencodePort}` });
52
+ // Track active session IDs by ticket ID to prevent/cancel overlaps
53
+ const activeSessions = {};
54
+ export class OpencodeAgent {
55
+ async run(ticket, config) {
56
+ console.log(`[opencode-agent] Starting session for ticket ${ticket.id}...`);
57
+ // Provide immediate visual feedback on the Frontend
58
+ ticketRepository.updateAgentSession(ticket.id, {
59
+ column_id: ticket.column_id,
60
+ agent_type: 'opencode',
61
+ status: 'processing',
62
+ port: Number(opencodePort)
63
+ });
64
+ // Prevent duplicate runs blocking new requests: replace the old session.
65
+ if (activeSessions[ticket.id]) {
66
+ console.log(`[opencode-agent] Session already running for ticket ${ticket.id}. Aborting old session to start a fresh one.`);
67
+ try {
68
+ // Ignore typescript error if abort typing differs slightly in SDK version
69
+ await opencodeClient.session.abort({ path: { id: activeSessions[ticket.id] } });
70
+ }
71
+ catch (e) {
72
+ console.error(`[opencode-agent] Error aborting previous session:`, e);
73
+ }
74
+ delete activeSessions[ticket.id];
75
+ }
76
+ // 1. Find Worktree (Use the directory where openboard was started)
77
+ let originalWorkspacePath = process.cwd();
78
+ // 2. Set up Worktree
79
+ // If this ticket already has a PR, we reuse the existing branch and worktree.
80
+ // Worktree paths are derived from branch name so they are stable across re-runs.
81
+ const latestTicket = ticketRepository.findById(ticket.id) || ticket;
82
+ const previousPrSession = [...(latestTicket.agent_sessions || [])].reverse().find(s => s.pr_url);
83
+ const existingPrUrl = previousPrSession?.pr_url;
84
+ let branchName;
85
+ let worktreePath;
86
+ if (existingPrUrl) {
87
+ // Ticket was already worked on — reuse the existing branch & worktree
88
+ console.log(`[opencode-agent] Found existing PR ${existingPrUrl}. Fetching branch name.`);
89
+ try {
90
+ const { stdout: prDataStr } = await runCmd('gh', ['pr', 'view', existingPrUrl, '--json', 'headRefName'], originalWorkspacePath);
91
+ const prData = JSON.parse(prDataStr);
92
+ if (!prData.headRefName)
93
+ throw new Error('Could not parse headRefName from PR');
94
+ branchName = prData.headRefName;
95
+ }
96
+ catch (ghErr) {
97
+ console.warn(`[opencode-agent] Could not read PR branch name, will create a new branch.`, ghErr.message);
98
+ branchName = `ticket-${ticket.id}-${Date.now()}`;
99
+ }
100
+ worktreePath = path.join(os.tmpdir(), 'openboard-worktrees', branchName);
101
+ }
102
+ else {
103
+ // Fresh ticket — create a new branch and worktree
104
+ branchName = `ticket-${ticket.id}-${Date.now()}`;
105
+ worktreePath = path.join(os.tmpdir(), 'openboard-worktrees', branchName);
106
+ }
107
+ try {
108
+ if (fs.existsSync(worktreePath)) {
109
+ // Worktree directory already on disk — just reuse it, no git command needed
110
+ console.log(`[opencode-agent] Reusing existing worktree at ${worktreePath} (branch: ${branchName})`);
111
+ }
112
+ else if (existingPrUrl) {
113
+ // Branch exists but worktree was cleaned up — check out the branch into the path
114
+ console.log(`[opencode-agent] Checking out existing branch ${branchName} into new worktree at ${worktreePath}`);
115
+ await runCmd('git', ['worktree', 'add', worktreePath, branchName], originalWorkspacePath);
116
+ }
117
+ else {
118
+ // Completely new — create branch and worktree together
119
+ console.log(`[opencode-agent] Creating new worktree at ${worktreePath} on branch ${branchName}`);
120
+ await runCmd('git', ['worktree', 'add', '-b', branchName, worktreePath], originalWorkspacePath);
121
+ }
122
+ }
123
+ catch (e) {
124
+ console.error(`[opencode-agent] Failed to create git worktree: ${e.message}`);
125
+ delete activeSessions[ticket.id];
126
+ ticketRepository.updateAgentSession(ticket.id, {
127
+ column_id: ticket.column_id,
128
+ agent_type: 'opencode',
129
+ status: 'blocked',
130
+ port: Number(opencodePort),
131
+ error_message: e.message
132
+ });
133
+ commentRepository.create({
134
+ ticketId: ticket.id,
135
+ author: 'opencode', // Use 'opencode' for consistency
136
+ content: `❌ **Failed to Initialize Worktree**\n\nThe agent could not prepare its isolated worktree.\nError: ${e.message}`
137
+ });
138
+ return;
139
+ }
140
+ // 3. Initialize Task
141
+ try {
142
+ // Create session
143
+ const session = await opencodeClient.session.create({
144
+ body: {
145
+ title: `Session for Ticket: ${ticket.title}`,
146
+ },
147
+ query: {
148
+ directory: worktreePath
149
+ }
150
+ });
151
+ if (!session.data)
152
+ throw new Error("Failed to create OpenCode session");
153
+ const sessionID = session.data.id;
154
+ activeSessions[ticket.id] = sessionID;
155
+ // Compute exact OpenCode Tracking URL
156
+ const encodedPath = Buffer.from(originalWorkspacePath).toString('base64url');
157
+ const agentUrl = `http://127.0.0.1:${opencodePort}/${encodedPath}/session/${sessionID}`;
158
+ ticketRepository.updateAgentSession(ticket.id, {
159
+ column_id: ticket.column_id,
160
+ agent_type: 'opencode',
161
+ status: 'processing',
162
+ port: Number(opencodePort),
163
+ url: agentUrl
164
+ });
165
+ // --- Background Event Stream Listener ---
166
+ const events = await opencodeClient.event.subscribe();
167
+ // Start the event listener in the background without awaiting it
168
+ setupOpencodeEventListener(events, opencodeClient, sessionID, ticket, agentUrl, config, activeSessions, worktreePath, originalWorkspacePath, branchName);
169
+ // ------------------------------------------
170
+ // Send first message asynchronously (doesn't wait for completion)
171
+ // Fetch GH token so the LLM environment can execute `gh` commands
172
+ let ghTokenEnv = '';
173
+ try {
174
+ const { stdout: ghToken } = await runCmd('gh', ['auth', 'token'], originalWorkspacePath);
175
+ if (ghToken.trim()) {
176
+ ghTokenEnv = `export GH_TOKEN=${ghToken.trim()}; `;
177
+ }
178
+ }
179
+ catch (authErr) {
180
+ console.warn(`[opencode-agent] Could not fetch GH token:`, authErr);
181
+ }
182
+ let promptText = `# TASK: ${ticket.title}\n\n## Description\n${ticket.description}\n\n## Instructions\n1. The current working directory you should focus on is ${worktreePath}.\n`;
183
+ if (existingPrUrl) {
184
+ promptText += `\n⚠️ **ATTENTION: CHANGES REQUESTED** ⚠️\nYou previously worked on this ticket and opened PR ${existingPrUrl}. However, changes were requested during code review.\n\nPlease use \`${ghTokenEnv}gh pr view ${existingPrUrl} --comments\` to read the requested changes, make the necessary code updates to fix the issues, and summarize your fixes.\n`;
185
+ }
186
+ const promptRes = await opencodeClient.session.promptAsync({
187
+ path: { id: sessionID },
188
+ body: {
189
+ parts: [{
190
+ type: "text",
191
+ text: promptText
192
+ }]
193
+ }
194
+ });
195
+ if (promptRes.error) {
196
+ throw new Error(`OpenCode session error: ${JSON.stringify(promptRes.error)}`);
197
+ }
198
+ console.log(`[opencode-agent] Agent task dispatched for ticket ${ticket.id}. Waiting for completion in background.`);
199
+ }
200
+ catch (e) {
201
+ console.error(`[opencode-agent] Failed to initialize task over SDK: ${e.message}`);
202
+ delete activeSessions[ticket.id];
203
+ // Mark the ticket as blocked due to error
204
+ ticketRepository.updateAgentSession(ticket.id, {
205
+ column_id: ticket.column_id,
206
+ agent_type: 'opencode',
207
+ status: 'blocked',
208
+ url: undefined,
209
+ error_message: e.message
210
+ });
211
+ // Add a comment to explicitly tell the user what went wrong
212
+ commentRepository.create({
213
+ ticketId: ticket.id,
214
+ author: 'opencode',
215
+ content: `❌ **Agent Execution Failed**\n\nThe OpenCode agent failed to process this ticket.\nError: ${e.message}`
216
+ });
217
+ }
218
+ }
219
+ }