@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.
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 +29 -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 +11 -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-CufaRX1M.js +0 -60
  37. package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +0 -1
  38. package/src/client/dist/spa/assets/CreatePage-Cyl-TRHT.js +0 -2
  39. package/src/client/dist/spa/assets/MainLayout-D_vxGAPn.css +0 -1
  40. package/src/client/dist/spa/assets/MainLayout-Dzy0I8lB.js +0 -1
  41. package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +0 -1
  42. package/src/client/dist/spa/assets/QDialog-CMC1Ph52.js +0 -1
  43. package/src/client/dist/spa/assets/QExpansionItem-DDjku8zz.js +0 -1
  44. package/src/client/dist/spa/assets/QPage-DaNo_vcd.js +0 -1
  45. package/src/client/dist/spa/assets/QTabPanels-BKHAAJ2p.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-Blw7Qk7m.js +0 -1
  49. package/src/client/dist/spa/assets/WorkspacePage-DlnwomOE.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-DJkEmbBM.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) {
@@ -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
- KOBO_BACKEND_URL: `http://localhost:${process.env.PORT ?? '3000'}`,
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 ─────────────────────────────────────────────────────────────────