@loicngr/kobo 1.1.0 → 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.
Files changed (53) hide show
  1. package/dist/mcp-server/kobo-tasks-handlers.js +147 -0
  2. package/dist/mcp-server/kobo-tasks-server.js +236 -29
  3. package/dist/server/index.js +4 -1
  4. package/dist/server/routes/images.js +57 -0
  5. package/dist/server/routes/workspaces.js +53 -5
  6. package/dist/server/services/agent-manager.js +14 -2
  7. package/dist/server/services/image-service.js +73 -0
  8. package/dist/server/services/settings-service.js +55 -9
  9. package/dist/server/services/workspace-service.js +6 -0
  10. package/dist/server/utils/git-ops.js +29 -0
  11. package/package.json +3 -1
  12. package/src/client/dist/spa/assets/ActivityFeed-CPfYmybV.js +60 -0
  13. package/src/client/dist/spa/assets/ActivityFeed-DBljh9rq.css +1 -0
  14. package/src/client/dist/spa/assets/CreatePage-C_c3Gr0F.js +2 -0
  15. package/src/client/dist/spa/assets/MainLayout-BMxEROm4.css +1 -0
  16. package/src/client/dist/spa/assets/MainLayout-QtbVbbnd.js +1 -0
  17. package/src/client/dist/spa/assets/QBadge-CNojh9Rl.js +1 -0
  18. package/src/client/dist/spa/assets/QDialog-DgR7t6Vf.js +1 -0
  19. package/src/client/dist/spa/assets/QExpansionItem-VVjlYOIT.js +1 -0
  20. package/src/client/dist/spa/assets/QPage-DX4g-Dpe.js +1 -0
  21. package/src/client/dist/spa/assets/{QSpinnerDots-DcaNq8uL.js → QSpinnerDots-DeCf9Lr-.js} +1 -1
  22. package/src/client/dist/spa/assets/QTooltip-DKYJ8kVW.js +1 -0
  23. package/src/client/dist/spa/assets/SettingsPage-DjWKsLC-.js +1 -0
  24. package/src/client/dist/spa/assets/SettingsPage-Yv31Z9aG.css +1 -0
  25. package/src/client/dist/spa/assets/WorkspacePage-DkM58caD.css +1 -0
  26. package/src/client/dist/spa/assets/WorkspacePage-EAh91w9s.js +2 -0
  27. package/src/client/dist/spa/assets/_plugin-vue_export-helper-C6NdfBK4.js +1 -0
  28. package/src/client/dist/spa/assets/index-C4WDJfjD.js +5 -0
  29. package/src/client/dist/spa/assets/{nodes-DeIen-kp.js → nodes-irfhA8FK.js} +1 -1
  30. package/src/client/dist/spa/assets/use-checkbox-BS9cbwg_.js +1 -0
  31. package/src/client/dist/spa/assets/use-quasar-CH0pSHUf.js +1 -0
  32. package/src/client/dist/spa/index.html +2 -2
  33. package/src/mcp-server/README.md +179 -0
  34. package/src/mcp-server/kobo-tasks-handlers.ts +238 -0
  35. package/src/mcp-server/kobo-tasks-server.ts +263 -29
  36. package/src/client/dist/spa/assets/ActivityFeed-B2jpghoY.js +0 -60
  37. package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +0 -1
  38. package/src/client/dist/spa/assets/CreatePage-BdmfCbtE.js +0 -2
  39. package/src/client/dist/spa/assets/MainLayout-CHyXeNrm.js +0 -1
  40. package/src/client/dist/spa/assets/MainLayout-D0OU6djX.css +0 -1
  41. package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +0 -1
  42. package/src/client/dist/spa/assets/QDialog-4OVnJT5b.js +0 -1
  43. package/src/client/dist/spa/assets/QExpansionItem-B7zh077K.js +0 -1
  44. package/src/client/dist/spa/assets/QPage-DKJI735G.js +0 -1
  45. package/src/client/dist/spa/assets/QTabPanels-BAa7ivXp.js +0 -1
  46. package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +0 -1
  47. package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +0 -1
  48. package/src/client/dist/spa/assets/SettingsPage-BEDCuLGe.js +0 -1
  49. package/src/client/dist/spa/assets/WorkspacePage-C6D1voGm.js +0 -2
  50. package/src/client/dist/spa/assets/WorkspacePage-HtatyhXN.css +0 -1
  51. package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +0 -1
  52. package/src/client/dist/spa/assets/index-BZyTYZ0E.js +0 -5
  53. 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
- if (name === 'list_tasks') {
71
- const tasks = listTasksHandler(db, workspaceId);
72
- return {
73
- content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }],
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
- try {
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
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
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
- catch (err) {
92
- const message = err instanceof Error ? err.message : String(err);
93
- return {
94
- content: [{ type: 'text', text: `Error: ${message}` }],
95
- isError: true,
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) => {
@@ -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
- const updated = workspaceService.updateWorkspaceStatus(id, body.status);
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) {
@@ -552,6 +574,32 @@ app.post('/:id/start', async (c) => {
552
574
  return c.json({ error: message }, 500);
553
575
  }
554
576
  });
577
+ // GET /api/workspaces/:id/git-stats — commit count and diff stats for the branch
578
+ app.get('/:id/git-stats', async (c) => {
579
+ try {
580
+ const id = c.req.param('id');
581
+ const workspace = workspaceService.getWorkspace(id);
582
+ if (!workspace) {
583
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
584
+ }
585
+ const path = await import('node:path');
586
+ const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
587
+ const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
588
+ const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
589
+ const prUrl = gitOps.getPrUrl(workspace.projectPath, workspace.workingBranch);
590
+ return c.json({
591
+ commitCount,
592
+ filesChanged: diffStats.filesChanged,
593
+ insertions: diffStats.insertions,
594
+ deletions: diffStats.deletions,
595
+ prUrl,
596
+ });
597
+ }
598
+ catch (err) {
599
+ const message = err instanceof Error ? err.message : String(err);
600
+ return c.json({ error: message }, 500);
601
+ }
602
+ });
555
603
  // POST /api/workspaces/:id/push — push working branch to origin
556
604
  app.post('/:id/push', async (c) => {
557
605
  try {