@loicngr/kobo 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp-server/kobo-tasks-handlers.js +147 -0
- package/dist/mcp-server/kobo-tasks-server.js +236 -29
- package/dist/server/index.js +4 -1
- package/dist/server/routes/images.js +57 -0
- package/dist/server/routes/workspaces.js +29 -5
- package/dist/server/services/agent-manager.js +14 -2
- package/dist/server/services/image-service.js +73 -0
- package/dist/server/services/settings-service.js +55 -9
- package/dist/server/services/workspace-service.js +6 -0
- package/dist/server/utils/git-ops.js +11 -0
- package/package.json +3 -1
- package/src/client/dist/spa/assets/ActivityFeed-CPfYmybV.js +60 -0
- package/src/client/dist/spa/assets/ActivityFeed-DBljh9rq.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-C_c3Gr0F.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-BMxEROm4.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-QtbVbbnd.js +1 -0
- package/src/client/dist/spa/assets/QBadge-CNojh9Rl.js +1 -0
- package/src/client/dist/spa/assets/QDialog-DgR7t6Vf.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-VVjlYOIT.js +1 -0
- package/src/client/dist/spa/assets/QPage-DX4g-Dpe.js +1 -0
- package/src/client/dist/spa/assets/{QSpinnerDots-DcaNq8uL.js → QSpinnerDots-DeCf9Lr-.js} +1 -1
- package/src/client/dist/spa/assets/QTooltip-DKYJ8kVW.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-DjWKsLC-.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-Yv31Z9aG.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-DkM58caD.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-EAh91w9s.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-C6NdfBK4.js +1 -0
- package/src/client/dist/spa/assets/index-C4WDJfjD.js +5 -0
- package/src/client/dist/spa/assets/{nodes-DeIen-kp.js → nodes-irfhA8FK.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-BS9cbwg_.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-CH0pSHUf.js +1 -0
- package/src/client/dist/spa/index.html +2 -2
- package/src/mcp-server/README.md +179 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +238 -0
- package/src/mcp-server/kobo-tasks-server.ts +263 -29
- package/src/client/dist/spa/assets/ActivityFeed-CufaRX1M.js +0 -60
- package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-Cyl-TRHT.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-D_vxGAPn.css +0 -1
- package/src/client/dist/spa/assets/MainLayout-Dzy0I8lB.js +0 -1
- package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +0 -1
- package/src/client/dist/spa/assets/QDialog-CMC1Ph52.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-DDjku8zz.js +0 -1
- package/src/client/dist/spa/assets/QPage-DaNo_vcd.js +0 -1
- package/src/client/dist/spa/assets/QTabPanels-BKHAAJ2p.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-Blw7Qk7m.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DlnwomOE.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-HtatyhXN.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +0 -1
- package/src/client/dist/spa/assets/index-DJkEmbBM.js +0 -5
- package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +0 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { nanoid } from 'nanoid';
|
|
4
|
+
export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
|
|
1
5
|
function rowToDto(row) {
|
|
2
6
|
return {
|
|
3
7
|
id: row.id,
|
|
@@ -25,3 +29,146 @@ export function markTaskDoneHandler(db, workspaceId, taskId) {
|
|
|
25
29
|
.get(taskId);
|
|
26
30
|
return { success: true, task: rowToDto(row) };
|
|
27
31
|
}
|
|
32
|
+
export function createTaskHandler(db, workspaceId, data) {
|
|
33
|
+
if (!data.title?.trim()) {
|
|
34
|
+
throw new Error('title is required');
|
|
35
|
+
}
|
|
36
|
+
// Verify workspace exists
|
|
37
|
+
const ws = db.prepare('SELECT id FROM workspaces WHERE id = ?').get(workspaceId);
|
|
38
|
+
if (!ws) {
|
|
39
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
40
|
+
}
|
|
41
|
+
const id = nanoid();
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const isAC = data.is_acceptance_criterion ? 1 : 0;
|
|
44
|
+
// Append at the end: max(sort_order) + 1
|
|
45
|
+
const maxRow = db
|
|
46
|
+
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS max FROM tasks WHERE workspace_id = ?')
|
|
47
|
+
.get(workspaceId);
|
|
48
|
+
const sortOrder = maxRow.max + 1;
|
|
49
|
+
db.prepare('INSERT INTO tasks (id, workspace_id, title, status, is_acceptance_criterion, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(id, workspaceId, data.title.trim(), 'pending', isAC, sortOrder, now, now);
|
|
50
|
+
const row = db.prepare('SELECT id, title, status, is_acceptance_criterion FROM tasks WHERE id = ?').get(id);
|
|
51
|
+
return rowToDto(row);
|
|
52
|
+
}
|
|
53
|
+
export function updateTaskHandler(db, workspaceId, taskId, data) {
|
|
54
|
+
// Verify task belongs to workspace
|
|
55
|
+
const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId);
|
|
56
|
+
if (!existing) {
|
|
57
|
+
throw new Error(`Task '${taskId}' not found in workspace '${workspaceId}'`);
|
|
58
|
+
}
|
|
59
|
+
const sets = [];
|
|
60
|
+
const values = [];
|
|
61
|
+
if (data.title !== undefined) {
|
|
62
|
+
if (!data.title.trim())
|
|
63
|
+
throw new Error('title cannot be empty');
|
|
64
|
+
sets.push('title = ?');
|
|
65
|
+
values.push(data.title.trim());
|
|
66
|
+
}
|
|
67
|
+
if (data.status !== undefined) {
|
|
68
|
+
if (!VALID_TASK_STATUSES.includes(data.status)) {
|
|
69
|
+
throw new Error(`Invalid status '${data.status}'. Must be one of: ${VALID_TASK_STATUSES.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
sets.push('status = ?');
|
|
72
|
+
values.push(data.status);
|
|
73
|
+
}
|
|
74
|
+
if (data.is_acceptance_criterion !== undefined) {
|
|
75
|
+
sets.push('is_acceptance_criterion = ?');
|
|
76
|
+
values.push(data.is_acceptance_criterion ? 1 : 0);
|
|
77
|
+
}
|
|
78
|
+
if (sets.length === 0) {
|
|
79
|
+
throw new Error('No fields to update (provide title, status, or is_acceptance_criterion)');
|
|
80
|
+
}
|
|
81
|
+
sets.push('updated_at = ?');
|
|
82
|
+
values.push(new Date().toISOString());
|
|
83
|
+
values.push(taskId);
|
|
84
|
+
db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...values);
|
|
85
|
+
const row = db
|
|
86
|
+
.prepare('SELECT id, title, status, is_acceptance_criterion FROM tasks WHERE id = ?')
|
|
87
|
+
.get(taskId);
|
|
88
|
+
return rowToDto(row);
|
|
89
|
+
}
|
|
90
|
+
export function deleteTaskHandler(db, workspaceId, taskId) {
|
|
91
|
+
const result = db.prepare('DELETE FROM tasks WHERE id = ? AND workspace_id = ?').run(taskId, workspaceId);
|
|
92
|
+
if (result.changes === 0) {
|
|
93
|
+
throw new Error(`Task '${taskId}' not found in workspace '${workspaceId}'`);
|
|
94
|
+
}
|
|
95
|
+
return { success: true, task_id: taskId };
|
|
96
|
+
}
|
|
97
|
+
export function getDevServerStatusHandler(db, workspaceId) {
|
|
98
|
+
const row = db.prepare('SELECT dev_server_status FROM workspaces WHERE id = ?').get(workspaceId);
|
|
99
|
+
if (!row) {
|
|
100
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
101
|
+
}
|
|
102
|
+
return { workspaceId, status: row.dev_server_status };
|
|
103
|
+
}
|
|
104
|
+
export function getSettingsHandler(settingsPath, projectPath) {
|
|
105
|
+
// Shape is determined solely by whether projectPath was provided:
|
|
106
|
+
// - with projectPath → { global, project }
|
|
107
|
+
// - without → { global, projects }
|
|
108
|
+
// The `error` field is added on top when settings are unavailable.
|
|
109
|
+
if (!settingsPath || !fs.existsSync(settingsPath)) {
|
|
110
|
+
const base = projectPath ? { global: null, project: null } : { global: null, projects: [] };
|
|
111
|
+
return { ...base, error: 'Settings file not available' };
|
|
112
|
+
}
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
throw new Error(`Failed to read settings: ${err instanceof Error ? err.message : String(err)}`);
|
|
119
|
+
}
|
|
120
|
+
const global = parsed.global ?? null;
|
|
121
|
+
const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
|
|
122
|
+
if (projectPath) {
|
|
123
|
+
const project = projects.find((p) => p.path === projectPath) ?? null;
|
|
124
|
+
return { global, project };
|
|
125
|
+
}
|
|
126
|
+
return { global, projects };
|
|
127
|
+
}
|
|
128
|
+
export function getWorkspaceInfoHandler(db, workspaceId) {
|
|
129
|
+
const row = db
|
|
130
|
+
.prepare('SELECT id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, model, dev_server_status, created_at, updated_at FROM workspaces WHERE id = ?')
|
|
131
|
+
.get(workspaceId);
|
|
132
|
+
if (!row) {
|
|
133
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
id: row.id,
|
|
137
|
+
name: row.name,
|
|
138
|
+
projectPath: row.project_path,
|
|
139
|
+
sourceBranch: row.source_branch,
|
|
140
|
+
workingBranch: row.working_branch,
|
|
141
|
+
worktreePath: path.join(row.project_path, '.worktrees', row.working_branch),
|
|
142
|
+
status: row.status,
|
|
143
|
+
model: row.model,
|
|
144
|
+
notionUrl: row.notion_url,
|
|
145
|
+
notionPageId: row.notion_page_id,
|
|
146
|
+
devServerStatus: row.dev_server_status,
|
|
147
|
+
createdAt: row.created_at,
|
|
148
|
+
updatedAt: row.updated_at,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function listWorkspaceImagesHandler(worktreePath) {
|
|
152
|
+
const imagesDir = path.join(worktreePath, '.ai', 'images');
|
|
153
|
+
const indexPath = path.join(imagesDir, 'index.json');
|
|
154
|
+
if (!fs.existsSync(indexPath))
|
|
155
|
+
return [];
|
|
156
|
+
let entries;
|
|
157
|
+
try {
|
|
158
|
+
entries = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
// Read directory once — imagesDir is guaranteed to exist because indexPath does
|
|
164
|
+
const files = fs.readdirSync(imagesDir);
|
|
165
|
+
return entries.map((e) => {
|
|
166
|
+
const match = files.find((f) => f.startsWith(`${e.uid}.`));
|
|
167
|
+
return {
|
|
168
|
+
uid: e.uid,
|
|
169
|
+
originalName: e.originalName,
|
|
170
|
+
relativePath: match ? path.join('.ai', 'images', match) : '',
|
|
171
|
+
createdAt: e.createdAt,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -3,9 +3,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import Database from 'better-sqlite3';
|
|
6
|
-
import { listTasksHandler, markTaskDoneHandler } from './kobo-tasks-handlers.js';
|
|
6
|
+
import { createTaskHandler, deleteTaskHandler, getDevServerStatusHandler, getSettingsHandler, getWorkspaceInfoHandler, listTasksHandler, listWorkspaceImagesHandler, markTaskDoneHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
|
|
7
7
|
const workspaceId = process.env.KOBO_WORKSPACE_ID;
|
|
8
8
|
const dbPath = process.env.KOBO_DB_PATH;
|
|
9
|
+
const settingsPath = process.env.KOBO_SETTINGS_PATH;
|
|
9
10
|
const backendUrl = process.env.KOBO_BACKEND_URL ?? 'http://localhost:3000';
|
|
10
11
|
if (!workspaceId) {
|
|
11
12
|
console.error('[kobo-tasks-server] KOBO_WORKSPACE_ID env var is required');
|
|
@@ -37,12 +38,36 @@ async function notifyBackend(taskId) {
|
|
|
37
38
|
console.error('[kobo-tasks-server] notify-done failed:', err);
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
async function notifyTasksUpdated() {
|
|
42
|
+
try {
|
|
43
|
+
const url = `${backendUrl}/api/workspaces/${workspaceId}/tasks/notify-updated`;
|
|
44
|
+
await fetch(url, { method: 'POST' });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.error('[kobo-tasks-server] notify-updated failed:', err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function backendRequest(method, pathname, body) {
|
|
51
|
+
const url = `${backendUrl}${pathname}`;
|
|
52
|
+
const init = { method };
|
|
53
|
+
if (body !== undefined) {
|
|
54
|
+
init.headers = { 'Content-Type': 'application/json' };
|
|
55
|
+
init.body = JSON.stringify(body);
|
|
56
|
+
}
|
|
57
|
+
const res = await fetch(url, init);
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const errText = await res.text().catch(() => '');
|
|
60
|
+
throw new Error(`Backend ${method} ${pathname} returned ${res.status}: ${errText}`);
|
|
61
|
+
}
|
|
62
|
+
const text = await res.text();
|
|
63
|
+
return text ? JSON.parse(text) : null;
|
|
64
|
+
}
|
|
40
65
|
const server = new Server({ name: 'kobo-tasks', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
41
66
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
42
67
|
tools: [
|
|
43
68
|
{
|
|
44
69
|
name: 'list_tasks',
|
|
45
|
-
description: 'List all tasks and acceptance criteria for the current workspace with their IDs and current status. Call this first to discover task IDs before calling mark_task_done.',
|
|
70
|
+
description: 'List all tasks and acceptance criteria for the current workspace with their IDs and current status. Call this first to discover task IDs before calling mark_task_done / update_task / delete_task.',
|
|
46
71
|
inputSchema: {
|
|
47
72
|
type: 'object',
|
|
48
73
|
properties: {},
|
|
@@ -63,43 +88,225 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
63
88
|
required: ['task_id'],
|
|
64
89
|
},
|
|
65
90
|
},
|
|
91
|
+
{
|
|
92
|
+
name: 'create_task',
|
|
93
|
+
description: 'Create a new task or acceptance criterion for the current workspace. Appended at the end of the list.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
title: { type: 'string', description: 'Task title' },
|
|
98
|
+
is_acceptance_criterion: {
|
|
99
|
+
type: 'boolean',
|
|
100
|
+
description: 'Whether this is an acceptance criterion (default: false)',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['title'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'update_task',
|
|
108
|
+
description: 'Update an existing task — change title, status, or is_acceptance_criterion flag. At least one field is required.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
task_id: { type: 'string', description: 'The ID of the task to update' },
|
|
113
|
+
title: { type: 'string', description: 'New title (optional)' },
|
|
114
|
+
status: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
enum: ['pending', 'in_progress', 'done'],
|
|
117
|
+
description: 'New status (optional)',
|
|
118
|
+
},
|
|
119
|
+
is_acceptance_criterion: {
|
|
120
|
+
type: 'boolean',
|
|
121
|
+
description: 'Toggle acceptance criterion flag (optional)',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: ['task_id'],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'delete_task',
|
|
129
|
+
description: 'Delete a task from the current workspace permanently.',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
task_id: { type: 'string', description: 'The ID of the task to delete' },
|
|
134
|
+
},
|
|
135
|
+
required: ['task_id'],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'get_settings',
|
|
140
|
+
description: 'Read Kōbō settings (global + projects). Optionally filter to a specific project by path to get both global and that project override.',
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
project_path: {
|
|
145
|
+
type: 'string',
|
|
146
|
+
description: 'Optional project path to resolve a specific project entry',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
required: [],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'get_dev_server_status',
|
|
154
|
+
description: 'Check whether the dev server is running for the current workspace.',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {},
|
|
158
|
+
required: [],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'get_workspace_info',
|
|
163
|
+
description: 'Get all metadata about the current workspace (name, project path, branches, model, notion URL, worktree path, status).',
|
|
164
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'start_dev_server',
|
|
168
|
+
description: 'Start the dev server configured for the current workspace.',
|
|
169
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'stop_dev_server',
|
|
173
|
+
description: 'Stop the dev server of the current workspace.',
|
|
174
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'get_dev_server_logs',
|
|
178
|
+
description: 'Fetch the last N lines of the dev server logs for the current workspace.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
tail: {
|
|
183
|
+
type: 'number',
|
|
184
|
+
description: 'Number of lines to fetch from the end (default: 200)',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: [],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'list_workspace_images',
|
|
192
|
+
description: 'List all images uploaded to the current workspace (from .ai/images/index.json). Returns uid, originalName, relativePath and createdAt for each image.',
|
|
193
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'get_git_info',
|
|
197
|
+
description: 'Get git stats for the current workspace (commit count, files changed, insertions, deletions, PR URL if any).',
|
|
198
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'set_workspace_status',
|
|
202
|
+
description: 'Update the current workspace status. Valid values: idle, completed, error. Transitions are validated by the backend.',
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
properties: {
|
|
206
|
+
status: {
|
|
207
|
+
type: 'string',
|
|
208
|
+
description: 'New status (e.g. idle, completed)',
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
required: ['status'],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
66
214
|
],
|
|
67
215
|
}));
|
|
216
|
+
function ok(data) {
|
|
217
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
218
|
+
}
|
|
219
|
+
function fail(message) {
|
|
220
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
221
|
+
}
|
|
68
222
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
69
223
|
const { name, arguments: args } = request.params;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
if (name === 'mark_task_done') {
|
|
77
|
-
const taskId = args?.task_id;
|
|
78
|
-
if (!taskId) {
|
|
79
|
-
return {
|
|
80
|
-
content: [{ type: 'text', text: 'Error: task_id parameter is required' }],
|
|
81
|
-
isError: true,
|
|
82
|
-
};
|
|
224
|
+
const a = (args ?? {});
|
|
225
|
+
try {
|
|
226
|
+
if (name === 'list_tasks') {
|
|
227
|
+
return ok(listTasksHandler(db, workspaceId));
|
|
83
228
|
}
|
|
84
|
-
|
|
229
|
+
if (name === 'mark_task_done') {
|
|
230
|
+
const taskId = a.task_id;
|
|
231
|
+
if (!taskId)
|
|
232
|
+
return fail('task_id parameter is required');
|
|
85
233
|
const result = markTaskDoneHandler(db, workspaceId, taskId);
|
|
86
234
|
void notifyBackend(taskId);
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
|
|
235
|
+
return ok(result);
|
|
236
|
+
}
|
|
237
|
+
if (name === 'create_task') {
|
|
238
|
+
const title = a.title;
|
|
239
|
+
if (!title)
|
|
240
|
+
return fail('title parameter is required');
|
|
241
|
+
const task = createTaskHandler(db, workspaceId, {
|
|
242
|
+
title,
|
|
243
|
+
is_acceptance_criterion: a.is_acceptance_criterion,
|
|
244
|
+
});
|
|
245
|
+
void notifyTasksUpdated();
|
|
246
|
+
return ok(task);
|
|
90
247
|
}
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
248
|
+
if (name === 'update_task') {
|
|
249
|
+
const taskId = a.task_id;
|
|
250
|
+
if (!taskId)
|
|
251
|
+
return fail('task_id parameter is required');
|
|
252
|
+
const task = updateTaskHandler(db, workspaceId, taskId, {
|
|
253
|
+
title: a.title,
|
|
254
|
+
status: a.status,
|
|
255
|
+
is_acceptance_criterion: a.is_acceptance_criterion,
|
|
256
|
+
});
|
|
257
|
+
void notifyTasksUpdated();
|
|
258
|
+
return ok(task);
|
|
97
259
|
}
|
|
260
|
+
if (name === 'delete_task') {
|
|
261
|
+
const taskId = a.task_id;
|
|
262
|
+
if (!taskId)
|
|
263
|
+
return fail('task_id parameter is required');
|
|
264
|
+
const result = deleteTaskHandler(db, workspaceId, taskId);
|
|
265
|
+
void notifyTasksUpdated();
|
|
266
|
+
return ok(result);
|
|
267
|
+
}
|
|
268
|
+
if (name === 'get_settings') {
|
|
269
|
+
return ok(getSettingsHandler(settingsPath, a.project_path));
|
|
270
|
+
}
|
|
271
|
+
if (name === 'get_dev_server_status') {
|
|
272
|
+
return ok(getDevServerStatusHandler(db, workspaceId));
|
|
273
|
+
}
|
|
274
|
+
if (name === 'get_workspace_info') {
|
|
275
|
+
return ok(getWorkspaceInfoHandler(db, workspaceId));
|
|
276
|
+
}
|
|
277
|
+
if (name === 'start_dev_server') {
|
|
278
|
+
const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/start`);
|
|
279
|
+
return ok(result);
|
|
280
|
+
}
|
|
281
|
+
if (name === 'stop_dev_server') {
|
|
282
|
+
const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/stop`);
|
|
283
|
+
return ok(result);
|
|
284
|
+
}
|
|
285
|
+
if (name === 'get_dev_server_logs') {
|
|
286
|
+
const tail = a.tail ?? 200;
|
|
287
|
+
const result = await backendRequest('GET', `/api/dev-server/${workspaceId}/logs?tail=${tail}`);
|
|
288
|
+
return ok(result);
|
|
289
|
+
}
|
|
290
|
+
if (name === 'list_workspace_images') {
|
|
291
|
+
const info = getWorkspaceInfoHandler(db, workspaceId);
|
|
292
|
+
return ok(listWorkspaceImagesHandler(info.worktreePath));
|
|
293
|
+
}
|
|
294
|
+
if (name === 'get_git_info') {
|
|
295
|
+
const result = await backendRequest('GET', `/api/workspaces/${workspaceId}/git-stats`);
|
|
296
|
+
return ok(result);
|
|
297
|
+
}
|
|
298
|
+
if (name === 'set_workspace_status') {
|
|
299
|
+
const status = a.status;
|
|
300
|
+
if (!status)
|
|
301
|
+
return fail('status parameter is required');
|
|
302
|
+
const result = await backendRequest('PATCH', `/api/workspaces/${workspaceId}`, { status });
|
|
303
|
+
return ok(result);
|
|
304
|
+
}
|
|
305
|
+
return fail(`Unknown tool: ${name}`);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
98
309
|
}
|
|
99
|
-
return {
|
|
100
|
-
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
101
|
-
isError: true,
|
|
102
|
-
};
|
|
103
310
|
});
|
|
104
311
|
const transport = new StdioServerTransport();
|
|
105
312
|
server.connect(transport).catch((err) => {
|
package/dist/server/index.js
CHANGED
|
@@ -9,10 +9,11 @@ import { getDb } from './db/index.js';
|
|
|
9
9
|
import { runMigrations } from './db/migrations.js';
|
|
10
10
|
import devServerRouter from './routes/dev-server.js';
|
|
11
11
|
import gitRouter from './routes/git.js';
|
|
12
|
+
import imagesRouter from './routes/images.js';
|
|
12
13
|
import notionRouter from './routes/notion.js';
|
|
13
14
|
import settingsRouter from './routes/settings.js';
|
|
14
15
|
import workspacesRouter from './routes/workspaces.js';
|
|
15
|
-
import { getAvailableSkills, sendMessage, startAgent, stopAgent } from './services/agent-manager.js';
|
|
16
|
+
import { getAvailableSkills, sendMessage, setBackendPort, startAgent, stopAgent } from './services/agent-manager.js';
|
|
16
17
|
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
17
18
|
import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
|
|
18
19
|
import { getLatestSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
|
|
@@ -39,6 +40,7 @@ const app = new Hono();
|
|
|
39
40
|
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
|
40
41
|
// 4. Mount route sub-routers
|
|
41
42
|
app.route('/api/workspaces', workspacesRouter);
|
|
43
|
+
app.route('/api/workspaces', imagesRouter);
|
|
42
44
|
app.route('/api/notion', notionRouter);
|
|
43
45
|
app.route('/api/git', gitRouter);
|
|
44
46
|
app.route('/api/settings', settingsRouter);
|
|
@@ -91,6 +93,7 @@ const server = serve({
|
|
|
91
93
|
fetch: app.fetch,
|
|
92
94
|
port: PORT,
|
|
93
95
|
}, (info) => {
|
|
96
|
+
setBackendPort(info.port);
|
|
94
97
|
console.log(`Server running at http://localhost:${info.port}`);
|
|
95
98
|
});
|
|
96
99
|
// 6. Create WebSocketServer attached to the HTTP server
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import * as imageService from '../services/image-service.js';
|
|
3
|
+
import * as workspaceService from '../services/workspace-service.js';
|
|
4
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
5
|
+
const ALLOWED_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
// POST /:id/images — upload an image
|
|
8
|
+
app.post('/:id/images', async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const { id } = c.req.param();
|
|
11
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
12
|
+
if (!workspace) {
|
|
13
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
14
|
+
}
|
|
15
|
+
const body = await c.req.parseBody();
|
|
16
|
+
const file = body.image;
|
|
17
|
+
if (!file || !(file instanceof File)) {
|
|
18
|
+
return c.json({ error: 'Missing image field in multipart body' }, 400);
|
|
19
|
+
}
|
|
20
|
+
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
|
21
|
+
return c.json({ error: `Unsupported MIME type: '${file.type}'. Allowed: ${[...ALLOWED_MIME_TYPES].join(', ')}` }, 400);
|
|
22
|
+
}
|
|
23
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
24
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
25
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
26
|
+
return c.json({ error: `File too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Max: 10 MB` }, 400);
|
|
27
|
+
}
|
|
28
|
+
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
29
|
+
const result = await imageService.saveImage(worktreePath, buffer, file.name);
|
|
30
|
+
return c.json({ uid: result.uid, path: result.relativePath }, 201);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34
|
+
return c.json({ error: message }, 500);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
// DELETE /:id/images/:uid — delete an uploaded image
|
|
38
|
+
app.delete('/:id/images/:uid', async (c) => {
|
|
39
|
+
try {
|
|
40
|
+
const { id, uid } = c.req.param();
|
|
41
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
42
|
+
if (!workspace) {
|
|
43
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
44
|
+
}
|
|
45
|
+
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
46
|
+
await imageService.deleteImage(worktreePath, uid);
|
|
47
|
+
return c.body(null, 204);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
if (message.includes('not found')) {
|
|
52
|
+
return c.json({ error: message }, 404);
|
|
53
|
+
}
|
|
54
|
+
return c.json({ error: message }, 500);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
export default app;
|
|
@@ -363,6 +363,22 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
|
363
363
|
return c.json({ error: message }, 500);
|
|
364
364
|
}
|
|
365
365
|
});
|
|
366
|
+
// POST /api/workspaces/:id/tasks/notify-updated — broadcast generic task list change
|
|
367
|
+
app.post('/:id/tasks/notify-updated', (c) => {
|
|
368
|
+
try {
|
|
369
|
+
const id = c.req.param('id');
|
|
370
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
371
|
+
if (!workspace) {
|
|
372
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
373
|
+
}
|
|
374
|
+
wsService.emit(id, 'task:updated', {});
|
|
375
|
+
return new Response(null, { status: 204 });
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
379
|
+
return c.json({ error: message }, 500);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
366
382
|
// GET /api/workspaces/archived — list archived workspaces (must be before GET /:id)
|
|
367
383
|
app.get('/archived', (c) => {
|
|
368
384
|
try {
|
|
@@ -388,19 +404,25 @@ app.get('/:id', (c) => {
|
|
|
388
404
|
return c.json({ error: message }, 500);
|
|
389
405
|
}
|
|
390
406
|
});
|
|
391
|
-
// PATCH /api/workspaces/:id — update workspace status
|
|
407
|
+
// PATCH /api/workspaces/:id — update workspace fields (status, model)
|
|
392
408
|
app.patch('/:id', async (c) => {
|
|
393
409
|
try {
|
|
394
410
|
const id = c.req.param('id');
|
|
395
411
|
const body = await c.req.json();
|
|
396
|
-
if (!body.status) {
|
|
397
|
-
return c.json({ error: 'Missing required field: status' }, 400);
|
|
398
|
-
}
|
|
399
412
|
const workspace = workspaceService.getWorkspace(id);
|
|
400
413
|
if (!workspace) {
|
|
401
414
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
402
415
|
}
|
|
403
|
-
|
|
416
|
+
let updated = workspace;
|
|
417
|
+
if (body.model !== undefined) {
|
|
418
|
+
updated = workspaceService.updateWorkspaceModel(id, body.model);
|
|
419
|
+
}
|
|
420
|
+
if (body.status) {
|
|
421
|
+
updated = workspaceService.updateWorkspaceStatus(id, body.status);
|
|
422
|
+
}
|
|
423
|
+
if (!body.status && body.model === undefined) {
|
|
424
|
+
return c.json({ error: 'Missing field: status or model' }, 400);
|
|
425
|
+
}
|
|
404
426
|
return c.json(updated);
|
|
405
427
|
}
|
|
406
428
|
catch (err) {
|
|
@@ -564,11 +586,13 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
564
586
|
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
565
587
|
const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
566
588
|
const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
589
|
+
const prUrl = gitOps.getPrUrl(workspace.projectPath, workspace.workingBranch);
|
|
567
590
|
return c.json({
|
|
568
591
|
commitCount,
|
|
569
592
|
filesChanged: diffStats.filesChanged,
|
|
570
593
|
insertions: diffStats.insertions,
|
|
571
594
|
deletions: diffStats.deletions,
|
|
595
|
+
prUrl,
|
|
572
596
|
});
|
|
573
597
|
}
|
|
574
598
|
catch (err) {
|
|
@@ -4,11 +4,17 @@ import path from 'node:path';
|
|
|
4
4
|
import readline from 'node:readline';
|
|
5
5
|
import { nanoid } from 'nanoid';
|
|
6
6
|
import { getDb } from '../db/index.js';
|
|
7
|
-
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath, getSkillsPath, } from '../utils/paths.js';
|
|
7
|
+
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../utils/paths.js';
|
|
8
8
|
import { registerProcess, unregisterProcess } from '../utils/process-tracker.js';
|
|
9
9
|
import { emit } from './websocket-service.js';
|
|
10
10
|
import { getWorkspace as getWs, listTasks, updateWorkspaceStatus } from './workspace-service.js';
|
|
11
11
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
/** Actual bound port of the running backend — set at startup via setBackendPort() */
|
|
13
|
+
let backendPort = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
|
14
|
+
/** Called from index.ts once the HTTP server is listening so MCP children can reach it. */
|
|
15
|
+
export function setBackendPort(port) {
|
|
16
|
+
backendPort = port;
|
|
17
|
+
}
|
|
12
18
|
/** workspaceId -> agent instance */
|
|
13
19
|
const agents = new Map();
|
|
14
20
|
/** workspaceId -> last Claude session ID (for --resume) */
|
|
@@ -37,6 +43,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
37
43
|
}
|
|
38
44
|
const db = getDb();
|
|
39
45
|
let agentSessionId;
|
|
46
|
+
let resumedClaudeSessionId;
|
|
40
47
|
// Build CLI args
|
|
41
48
|
const args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
|
|
42
49
|
if (model && model !== 'auto') {
|
|
@@ -48,6 +55,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
48
55
|
.get(workspaceId);
|
|
49
56
|
const claudeSessionId = sessionIds.get(workspaceId) ?? lastSession?.claude_session_id;
|
|
50
57
|
if (claudeSessionId) {
|
|
58
|
+
resumedClaudeSessionId = claudeSessionId;
|
|
51
59
|
args.push('--resume', claudeSessionId, '-p', prompt);
|
|
52
60
|
// Always reuse existing session — find by claude_session_id if lastSession didn't match
|
|
53
61
|
const existingId = lastSession?.id ??
|
|
@@ -86,7 +94,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
86
94
|
env: {
|
|
87
95
|
KOBO_WORKSPACE_ID: workspaceId,
|
|
88
96
|
KOBO_DB_PATH: getDbPath(),
|
|
89
|
-
|
|
97
|
+
KOBO_SETTINGS_PATH: getSettingsPath(),
|
|
98
|
+
KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
|
|
90
99
|
},
|
|
91
100
|
},
|
|
92
101
|
},
|
|
@@ -117,6 +126,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
117
126
|
rl,
|
|
118
127
|
status: 'running',
|
|
119
128
|
agentSessionId,
|
|
129
|
+
claudeSessionId: resumedClaudeSessionId,
|
|
120
130
|
};
|
|
121
131
|
// ── stdout line-by-line (NDJSON) ──
|
|
122
132
|
rl.on('line', (line) => {
|
|
@@ -287,6 +297,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
287
297
|
});
|
|
288
298
|
// Store in agents map
|
|
289
299
|
agents.set(workspaceId, agent);
|
|
300
|
+
// Notify frontend that agent is now running
|
|
301
|
+
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.claudeSessionId);
|
|
290
302
|
return agent;
|
|
291
303
|
}
|
|
292
304
|
// ── Stop agent ─────────────────────────────────────────────────────────────────
|