@loicngr/kobo 1.4.4 → 1.4.6

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 (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +2 -2
  3. package/dist/mcp-server/kobo-tasks-handlers.js +12 -1
  4. package/dist/mcp-server/kobo-tasks-server.js +14 -2
  5. package/dist/server/db/index.js +5 -0
  6. package/dist/server/db/migrations.js +9 -0
  7. package/dist/server/db/schema.js +2 -0
  8. package/dist/server/index.js +17 -18
  9. package/dist/server/routes/dev-server.js +1 -0
  10. package/dist/server/routes/git.js +1 -0
  11. package/dist/server/routes/images.js +3 -0
  12. package/dist/server/routes/notion.js +1 -0
  13. package/dist/server/routes/settings.js +1 -0
  14. package/dist/server/routes/workspaces.js +128 -31
  15. package/dist/server/services/agent-manager.js +32 -8
  16. package/dist/server/services/dev-server-service.js +7 -2
  17. package/dist/server/services/image-service.js +2 -1
  18. package/dist/server/services/notion-service.js +3 -16
  19. package/dist/server/services/pr-watcher-service.js +2 -0
  20. package/dist/server/services/settings-service.js +17 -6
  21. package/dist/server/services/setup-script-service.js +1 -0
  22. package/dist/server/services/websocket-service.js +8 -9
  23. package/dist/server/services/workspace-service.js +33 -2
  24. package/dist/server/services/worktree-service.js +4 -2
  25. package/dist/server/utils/git-ops.js +19 -5
  26. package/dist/server/utils/process-tracker.js +7 -0
  27. package/package.json +1 -1
  28. package/src/client/dist/spa/assets/ActivityFeed-CLqD89Cm.css +1 -0
  29. package/src/client/dist/spa/assets/ActivityFeed-DTH_Ij7C.js +68 -0
  30. package/src/client/dist/spa/assets/CreatePage-BB7McDfT.js +2 -0
  31. package/src/client/dist/spa/assets/CreatePage-DAZADSsw.css +1 -0
  32. package/src/client/dist/spa/assets/DiffViewer-DpdWjInq.css +1 -0
  33. package/src/client/dist/spa/assets/DiffViewer-DsVIBuje.js +2 -0
  34. package/src/client/dist/spa/assets/MainLayout-BVmmyrJW.js +2 -0
  35. package/src/client/dist/spa/assets/MainLayout-POre8X3k.css +1 -0
  36. package/src/client/dist/spa/assets/QBadge-C_R3Tjb9.js +1 -0
  37. package/src/client/dist/spa/assets/QExpansionItem-BPtEjGYj.js +1 -0
  38. package/src/client/dist/spa/assets/QPage-BX_DOfKi.js +1 -0
  39. package/src/client/dist/spa/assets/QSeparator-y-UWrZSp.js +1 -0
  40. package/src/client/dist/spa/assets/QSpinnerDots-vpiOHlmN.js +1 -0
  41. package/src/client/dist/spa/assets/QTooltip-D5Om2o3Y.js +1 -0
  42. package/src/client/dist/spa/assets/SettingsPage-BKDbZp9_.js +1 -0
  43. package/src/client/dist/spa/assets/SettingsPage-Dv9gCOw8.css +1 -0
  44. package/src/client/dist/spa/assets/WorkspacePage-Cxt0YZv0.css +1 -0
  45. package/src/client/dist/spa/assets/WorkspacePage-g-Y3BuoI.js +2 -0
  46. package/src/client/dist/spa/assets/_plugin-vue_export-helper-BZV6EEeb.js +1 -0
  47. package/src/client/dist/spa/assets/{cssMode-Dsa4Lydc.js → cssMode-CbCp8SFU.js} +1 -1
  48. package/src/client/dist/spa/assets/{editor.api-qmVdKoc0.js → editor.api-Bm9nrcuM.js} +1 -1
  49. package/src/client/dist/spa/assets/{editor.main-D2fZAQLs.js → editor.main-DCE1BHWQ.js} +3 -3
  50. package/src/client/dist/spa/assets/{freemarker2-e-FYsZTq.js → freemarker2-BEroNSFG.js} +1 -1
  51. package/src/client/dist/spa/assets/{handlebars-CAwfoT2m.js → handlebars-C8QC92C9.js} +1 -1
  52. package/src/client/dist/spa/assets/{html-BTRUpMfA.js → html-Dh4rYZTt.js} +1 -1
  53. package/src/client/dist/spa/assets/{htmlMode-mzgQeoHf.js → htmlMode-BUP5qnTw.js} +1 -1
  54. package/src/client/dist/spa/assets/i18n-CvzmE5dV.js +1 -0
  55. package/src/client/dist/spa/assets/index-BRIQl1ry.js +5 -0
  56. package/src/client/dist/spa/assets/{javascript-DDeQuxhB.js → javascript-C3VBJatM.js} +1 -1
  57. package/src/client/dist/spa/assets/{jsonMode-Ch5vu2Iw.js → jsonMode-fNrYYkV5.js} +1 -1
  58. package/src/client/dist/spa/assets/{liquid-qVj3a9kh.js → liquid-BnBhHusK.js} +1 -1
  59. package/src/client/dist/spa/assets/{mdx-Cb_a7RXe.js → mdx-CTu1vGiw.js} +1 -1
  60. package/src/client/dist/spa/assets/{monaco.contribution-Rz70-mfd.js → monaco.contribution-Byxe-pOH.js} +2 -2
  61. package/src/client/dist/spa/assets/nodes-THUz-Chh.js +1 -0
  62. package/src/client/dist/spa/assets/{python-Cz0peIkX.js → python-CBrfgeGm.js} +1 -1
  63. package/src/client/dist/spa/assets/{razor-CU8Xe-4p.js → razor-CaMsFgrW.js} +1 -1
  64. package/src/client/dist/spa/assets/settings-CYWSNYAA.js +1 -0
  65. package/src/client/dist/spa/assets/{tsMode-CP1svEaN.js → tsMode-CrwxLvLR.js} +1 -1
  66. package/src/client/dist/spa/assets/{typescript-aPJGGIsI.js → typescript-BOH3igZy.js} +1 -1
  67. package/src/client/dist/spa/assets/use-checkbox-ts4I7GAt.js +1 -0
  68. package/src/client/dist/spa/assets/use-quasar-CIVlxSZ-.js +1 -0
  69. package/src/client/dist/spa/assets/{xml-CZM1zQhV.js → xml-BtA9_-M9.js} +1 -1
  70. package/src/client/dist/spa/assets/{yaml-B8qPirw0.js → yaml-vYOBELrg.js} +1 -1
  71. package/src/client/dist/spa/index.html +3 -3
  72. package/src/mcp-server/kobo-tasks-handlers.ts +21 -5
  73. package/src/mcp-server/kobo-tasks-server.ts +14 -2
  74. package/src/client/dist/spa/assets/ActivityFeed-Bx7maW4r.css +0 -1
  75. package/src/client/dist/spa/assets/ActivityFeed-DoQIjq5C.js +0 -60
  76. package/src/client/dist/spa/assets/CreatePage-6J0aDFtf.js +0 -2
  77. package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +0 -1
  78. package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +0 -1
  79. package/src/client/dist/spa/assets/DiffViewer-dvGJaxu-.js +0 -2
  80. package/src/client/dist/spa/assets/MainLayout-DGzPKBi9.js +0 -2
  81. package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +0 -1
  82. package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +0 -1
  83. package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +0 -1
  84. package/src/client/dist/spa/assets/QExpansionItem-D5gm2xc8.js +0 -1
  85. package/src/client/dist/spa/assets/QPage-Bg3Rohl6.js +0 -1
  86. package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +0 -1
  87. package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +0 -1
  88. package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +0 -1
  89. package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +0 -1
  90. package/src/client/dist/spa/assets/SettingsPage-BOkWnRl2.js +0 -1
  91. package/src/client/dist/spa/assets/WorkspacePage-9gRnhdjv.js +0 -2
  92. package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +0 -1
  93. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +0 -1
  94. package/src/client/dist/spa/assets/i18n-B1eQvEGk.js +0 -1
  95. package/src/client/dist/spa/assets/index-C0u2YcnZ.js +0 -5
  96. package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +0 -1
  97. package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +0 -1
  98. package/src/client/dist/spa/assets/use-checkbox-B8W131xl.js +0 -1
  99. package/src/client/dist/spa/assets/use-quasar-sc8fDqi0.js +0 -1
  100. /package/src/client/dist/spa/assets/{formatters-CXx5Gzsp.js → formatters-B3FG1fMI.js} +0 -0
@@ -105,6 +105,7 @@ function defaultSettings() {
105
105
  dangerouslySkipPermissions: true,
106
106
  prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
107
107
  gitConventions: DEFAULT_GIT_CONVENTIONS,
108
+ editorCommand: '',
108
109
  },
109
110
  projects: [],
110
111
  };
@@ -128,11 +129,7 @@ function defaultProjectSettings(projectPath) {
128
129
  function pickKnownKeys(data, allowedKeys) {
129
130
  return Object.fromEntries(Object.entries(data).filter(([key]) => allowedKeys.includes(key)));
130
131
  }
131
- /**
132
- * Apply migrations sequentially to bring an older settings object up to
133
- * SETTINGS_SCHEMA_VERSION. Append-only — never edit or reorder shipped entries.
134
- * The returned object carries the bumped schemaVersion; callers persist it.
135
- */
132
+ /** Apply settings migrations sequentially up to SETTINGS_SCHEMA_VERSION. Append-only. */
136
133
  export function runSettingsMigrations(raw) {
137
134
  const current = raw;
138
135
  if (!current.global || typeof current.global !== 'object') {
@@ -190,16 +187,20 @@ function writeSettings(settings) {
190
187
  fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), 'utf-8');
191
188
  fs.renameSync(tmpPath, settingsFilePath);
192
189
  }
190
+ /** Read and return the full settings object, creating defaults if missing. */
193
191
  export function getSettings() {
194
192
  return readSettings();
195
193
  }
194
+ /** Return only the global settings section. */
196
195
  export function getGlobalSettings() {
197
196
  return readSettings().global;
198
197
  }
198
+ /** Return project-specific settings, or null if the project is not configured. */
199
199
  export function getProjectSettings(projectPath) {
200
200
  const settings = readSettings();
201
201
  return settings.projects.find((p) => p.path === projectPath) ?? null;
202
202
  }
203
+ /** Compute effective settings for a project (project overrides merged with global defaults). */
203
204
  export function getEffectiveSettings(projectPath) {
204
205
  const settings = readSettings();
205
206
  const project = settings.projects.find((p) => p.path === projectPath) ?? null;
@@ -224,14 +225,22 @@ export function getEffectiveSettings(projectPath) {
224
225
  setupScript: project.setupScript || '',
225
226
  };
226
227
  }
228
+ /** Merge partial updates into global settings and persist. */
227
229
  export function updateGlobalSettings(data) {
228
230
  const settings = readSettings();
229
- const allowedGlobalKeys = ['defaultModel', 'dangerouslySkipPermissions', 'prPromptTemplate', 'gitConventions'];
231
+ const allowedGlobalKeys = [
232
+ 'defaultModel',
233
+ 'dangerouslySkipPermissions',
234
+ 'prPromptTemplate',
235
+ 'gitConventions',
236
+ 'editorCommand',
237
+ ];
230
238
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
231
239
  settings.global = { ...settings.global, ...filtered };
232
240
  writeSettings(settings);
233
241
  return settings.global;
234
242
  }
243
+ /** Create or update project-specific settings. Merges devServer fields on update. */
235
244
  export function upsertProject(projectPath, data) {
236
245
  const allowedProjectKeys = [
237
246
  'displayName',
@@ -276,11 +285,13 @@ export function upsertProject(projectPath, data) {
276
285
  writeSettings(settings);
277
286
  return settings.projects.find((p) => p.path === projectPath);
278
287
  }
288
+ /** Remove a project from the settings file. */
279
289
  export function deleteProject(projectPath) {
280
290
  const settings = readSettings();
281
291
  settings.projects = settings.projects.filter((p) => p.path !== projectPath);
282
292
  writeSettings(settings);
283
293
  }
294
+ /** List all configured projects. */
284
295
  export function listProjects() {
285
296
  return readSettings().projects;
286
297
  }
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import * as wsService from './websocket-service.js';
5
5
  const SETUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
6
+ /** Execute a setup script in a worktree, streaming output via WebSocket. Resolves with the exit code. */
6
7
  export function runSetupScript(workspaceId, worktreePath, script, env, timeoutMs = SETUP_TIMEOUT_MS) {
7
8
  return new Promise((resolve) => {
8
9
  const scriptPath = path.join(worktreePath, '.ai', '.setup-script.tmp');
@@ -3,14 +3,16 @@ import { getDb } from '../db/index.js';
3
3
  // ── State ──────────────────────────────────────────────────────────────────────
4
4
  /** Maps each WS client to the set of workspaceIds they are subscribed to */
5
5
  const clients = new Map();
6
- /** I6: Per-workspace emit counter for periodic cleanup */
6
+ /** Per-workspace emit counter for periodic cleanup. */
7
7
  const emitCounters = new Map();
8
8
  const EMIT_CLEANUP_THRESHOLD = 2000;
9
9
  let messageHandler = null;
10
+ /** Register the handler that processes routed WS messages (e.g. chat:message, workspace:start). */
10
11
  export function setMessageHandler(handler) {
11
12
  messageHandler = handler;
12
13
  }
13
14
  // ── Connection handling ────────────────────────────────────────────────────────
15
+ /** Handle a new WebSocket connection: register, dispatch messages, ping keepalive. */
14
16
  export function handleConnection(ws) {
15
17
  // Register client with empty subscription set
16
18
  clients.set(ws, new Set());
@@ -50,7 +52,6 @@ export function handleConnection(ws) {
50
52
  case 'sync:request': {
51
53
  const p = payload;
52
54
  const lastEventId = p?.lastEventId ?? '';
53
- // I2: Accept optional workspaceIds so the client can sync even before re-subscribing
54
55
  const workspaceIds = p?.workspaceIds;
55
56
  handleSyncRequest(ws, lastEventId, workspaceIds);
56
57
  break;
@@ -97,11 +98,11 @@ export function handleConnection(ws) {
97
98
  export function emit(workspaceId, type, payload, sessionId) {
98
99
  const id = nanoid();
99
100
  const createdAt = new Date().toISOString();
100
- // C3: Persist to DB best-effort only; don't let FK violation (deleted workspace) break the broadcast
101
+ // Best-effort persist — don't let FK violation (deleted workspace) break the broadcast
101
102
  try {
102
103
  const db = getDb();
103
104
  db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
104
- // I6: Periodic cleanup — increment counter and trigger cleanup when threshold is reached
105
+ // Periodic cleanup — trigger when emit threshold is reached
105
106
  const count = (emitCounters.get(workspaceId) ?? 0) + 1;
106
107
  if (count >= EMIT_CLEANUP_THRESHOLD) {
107
108
  cleanupOldEvents(workspaceId);
@@ -142,12 +143,10 @@ export function emitEphemeral(workspaceId, type, payload) {
142
143
  }
143
144
  // ── Sync (replay missed events) ────────────────────────────────────────────────
144
145
  /**
145
- * Sends all events after lastEventId for workspaces the client is subscribed to.
146
- * I2: If workspaceIds is provided, use those instead of the client's current subscriptions
147
- * so the client can sync even before re-subscribing (e.g. after a reconnect).
146
+ * Replay all events after lastEventId for workspaces the client is subscribed to.
147
+ * If workspaceIds is provided, uses those instead of the client's current subscriptions.
148
148
  */
149
149
  export function handleSyncRequest(ws, lastEventId, workspaceIds) {
150
- // I2: Use provided workspaceIds first, fall back to current subscriptions
151
150
  const resolvedIds = workspaceIds && workspaceIds.length > 0
152
151
  ? workspaceIds
153
152
  : (() => {
@@ -171,7 +170,7 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
171
170
  .all(...resolvedIds, lastRow.rowid);
172
171
  }
173
172
  else {
174
- // I7: lastEventId not found — send events capped to avoid unbounded memory usage
173
+ // lastEventId not found — send events capped to avoid unbounded memory usage
175
174
  rows = db
176
175
  .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC LIMIT 10000`)
177
176
  .all(...resolvedIds);
@@ -1,6 +1,6 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
- // Valid status transitions
3
+ /** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
4
4
  const VALID_TRANSITIONS = {
5
5
  created: ['extracting', 'brainstorming', 'idle', 'error'],
6
6
  extracting: ['extracting', 'brainstorming', 'idle', 'error'],
@@ -24,6 +24,7 @@ function mapWorkspace(row) {
24
24
  model: row.model,
25
25
  permissionMode: (row.permission_mode ?? 'auto-accept'),
26
26
  devServerStatus: row.dev_server_status,
27
+ hasUnread: row.has_unread === 1,
27
28
  archivedAt: row.archived_at,
28
29
  createdAt: row.created_at,
29
30
  updatedAt: row.updated_at,
@@ -41,6 +42,7 @@ function mapTask(row) {
41
42
  updatedAt: row.updated_at,
42
43
  };
43
44
  }
45
+ /** Insert a new workspace into the database and return it. */
44
46
  export function createWorkspace(data) {
45
47
  const db = getDb();
46
48
  const now = new Date().toISOString();
@@ -51,11 +53,13 @@ export function createWorkspace(data) {
51
53
  `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.model ?? 'claude-opus-4-6', now, now);
52
54
  return getWorkspace(id);
53
55
  }
56
+ /** Fetch a single workspace by ID, or null if not found. */
54
57
  export function getWorkspace(id) {
55
58
  const db = getDb();
56
59
  const row = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
57
60
  return row ? mapWorkspace(row) : null;
58
61
  }
62
+ /** List all workspaces, optionally including archived ones. Ordered by most recently updated. */
59
63
  export function listWorkspaces(includeArchived = false) {
60
64
  const db = getDb();
61
65
  const sql = includeArchived
@@ -64,6 +68,7 @@ export function listWorkspaces(includeArchived = false) {
64
68
  const rows = db.prepare(sql).all();
65
69
  return rows.map(mapWorkspace);
66
70
  }
71
+ /** List only archived workspaces, ordered by archive date descending. */
67
72
  export function listArchivedWorkspaces() {
68
73
  const db = getDb();
69
74
  const rows = db
@@ -71,6 +76,7 @@ export function listArchivedWorkspaces() {
71
76
  .all();
72
77
  return rows.map(mapWorkspace);
73
78
  }
79
+ /** Transition a workspace to a new status, validating against VALID_TRANSITIONS. */
74
80
  export function updateWorkspaceStatus(id, status) {
75
81
  const db = getDb();
76
82
  const workspace = getWorkspace(id);
@@ -85,6 +91,7 @@ export function updateWorkspaceStatus(id, status) {
85
91
  db.prepare('UPDATE workspaces SET status = ?, updated_at = ? WHERE id = ?').run(status, now, id);
86
92
  return getWorkspace(id);
87
93
  }
94
+ /** Update a workspace's display name. */
88
95
  export function updateWorkspaceName(id, name) {
89
96
  const db = getDb();
90
97
  const now = new Date().toISOString();
@@ -94,6 +101,7 @@ export function updateWorkspaceName(id, name) {
94
101
  }
95
102
  return getWorkspace(id);
96
103
  }
104
+ /** Update the Claude model used by a workspace's agent. */
97
105
  export function updateWorkspaceModel(id, model) {
98
106
  const db = getDb();
99
107
  const now = new Date().toISOString();
@@ -103,6 +111,7 @@ export function updateWorkspaceModel(id, model) {
103
111
  }
104
112
  return getWorkspace(id);
105
113
  }
114
+ /** Update the agent's permission mode (auto-accept vs plan/read-only). */
106
115
  export function updateWorkspacePermissionMode(id, permissionMode) {
107
116
  const db = getDb();
108
117
  const now = new Date().toISOString();
@@ -114,17 +123,29 @@ export function updateWorkspacePermissionMode(id, permissionMode) {
114
123
  }
115
124
  return getWorkspace(id);
116
125
  }
126
+ /** Update the dev-server status column for a workspace. */
117
127
  export function updateDevServerStatus(id, status) {
118
128
  const db = getDb();
119
129
  db.prepare('UPDATE workspaces SET dev_server_status = ? WHERE id = ?').run(status, id);
120
130
  }
131
+ /** Mark a workspace as read (has_unread = 0). */
132
+ export function markWorkspaceRead(id) {
133
+ const db = getDb();
134
+ db.prepare('UPDATE workspaces SET has_unread = 0 WHERE id = ?').run(id);
135
+ }
136
+ /** Mark a workspace as unread (has_unread = 1). */
137
+ export function markWorkspaceUnread(id) {
138
+ const db = getDb();
139
+ db.prepare('UPDATE workspaces SET has_unread = 1 WHERE id = ?').run(id);
140
+ }
141
+ /** Delete a workspace and cascade-delete its tasks. */
121
142
  export function deleteWorkspace(id) {
122
143
  const db = getDb();
123
144
  db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
124
145
  }
146
+ /** Create a new task under a workspace. Throws if the workspace does not exist. */
125
147
  export function createTask(workspaceId, data) {
126
148
  const db = getDb();
127
- // I2: explicit workspace existence check before INSERT
128
149
  const exists = db.prepare('SELECT 1 FROM workspaces WHERE id = ?').get(workspaceId);
129
150
  if (!exists) {
130
151
  throw new Error(`Workspace not found: '${workspaceId}'`);
@@ -138,11 +159,13 @@ export function createTask(workspaceId, data) {
138
159
  const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
139
160
  return mapTask(row);
140
161
  }
162
+ /** Fetch a single task by ID scoped to a workspace, or null if not found. */
141
163
  export function getTask(taskId, workspaceId) {
142
164
  const db = getDb();
143
165
  const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId);
144
166
  return row ? mapTask(row) : null;
145
167
  }
168
+ /** List all tasks for a workspace, ordered by sort_order ascending. */
146
169
  export function listTasks(workspaceId) {
147
170
  const db = getDb();
148
171
  const rows = db
@@ -150,6 +173,7 @@ export function listTasks(workspaceId) {
150
173
  .all(workspaceId);
151
174
  return rows.map(mapTask);
152
175
  }
176
+ /** Update a task's status (pending, in_progress, done). */
153
177
  export function updateTaskStatus(taskId, status) {
154
178
  const db = getDb();
155
179
  const now = new Date().toISOString();
@@ -160,6 +184,7 @@ export function updateTaskStatus(taskId, status) {
160
184
  }
161
185
  return mapTask(row);
162
186
  }
187
+ /** Update a task's title. Throws if the title is empty or the task does not exist. */
163
188
  export function updateTaskTitle(taskId, title) {
164
189
  if (!title?.trim()) {
165
190
  throw new Error('Task title cannot be empty');
@@ -173,10 +198,12 @@ export function updateTaskTitle(taskId, title) {
173
198
  const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
174
199
  return mapTask(row);
175
200
  }
201
+ /** Delete a task by ID. */
176
202
  export function deleteTask(taskId) {
177
203
  const db = getDb();
178
204
  db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
179
205
  }
206
+ /** Fetch a workspace with all its tasks eagerly loaded. */
180
207
  export function getWorkspaceWithTasks(id) {
181
208
  const workspace = getWorkspace(id);
182
209
  if (!workspace)
@@ -184,6 +211,7 @@ export function getWorkspaceWithTasks(id) {
184
211
  const tasks = listTasks(id);
185
212
  return { ...workspace, tasks };
186
213
  }
214
+ /** Archive a workspace (set archived_at). Throws if already archived. */
187
215
  export function archiveWorkspace(id) {
188
216
  const db = getDb();
189
217
  const workspace = getWorkspace(id);
@@ -197,6 +225,7 @@ export function archiveWorkspace(id) {
197
225
  db.prepare('UPDATE workspaces SET archived_at = ?, updated_at = ? WHERE id = ?').run(now, now, id);
198
226
  return getWorkspace(id);
199
227
  }
228
+ /** Unarchive a workspace (clear archived_at), restoring its previous status. */
200
229
  export function unarchiveWorkspace(id) {
201
230
  const db = getDb();
202
231
  const workspace = getWorkspace(id);
@@ -221,6 +250,7 @@ function mapSession(row) {
221
250
  endedAt: row.ended_at,
222
251
  };
223
252
  }
253
+ /** List all agent sessions for a workspace, most recent first. */
224
254
  export function listSessions(workspaceId) {
225
255
  const db = getDb();
226
256
  const rows = db
@@ -228,6 +258,7 @@ export function listSessions(workspaceId) {
228
258
  .all(workspaceId);
229
259
  return rows.map(mapSession);
230
260
  }
261
+ /** Get the most recent agent session for a workspace, or null if none exist. */
231
262
  export function getLatestSession(workspaceId) {
232
263
  const db = getDb();
233
264
  const row = db
@@ -35,10 +35,10 @@ function removeFromExclude(projectPath, worktreePath) {
35
35
  const entry = `/${relativePath}`;
36
36
  const lines = fs.readFileSync(excludeFile, 'utf-8').split('\n');
37
37
  const filtered = lines.filter((line) => line !== entry);
38
- // I3: ensure the file ends with exactly one newline and has no trailing empty lines
39
38
  const trimmed = filtered.join('\n').replace(/\n+$/, '');
40
39
  fs.writeFileSync(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
41
40
  }
41
+ /** Create a git worktree under `.worktrees/` for the given branch. Returns the worktree path. */
42
42
  export function createWorktree(projectPath, branchName, sourceBranch) {
43
43
  const worktreesDir = path.join(projectPath, '.worktrees');
44
44
  if (!fs.existsSync(worktreesDir)) {
@@ -51,7 +51,6 @@ export function createWorktree(projectPath, branchName, sourceBranch) {
51
51
  }
52
52
  catch (err) {
53
53
  const message = err instanceof Error ? err.message : String(err);
54
- // M3: use shared utility for branch-exists detection
55
54
  // If branch already exists, add worktree without creating the branch
56
55
  if (isGitBranchExistsError(message)) {
57
56
  git(projectPath, ['worktree', 'add', worktreePath, branchName]);
@@ -63,6 +62,7 @@ export function createWorktree(projectPath, branchName, sourceBranch) {
63
62
  addToExclude(projectPath, worktreePath);
64
63
  return worktreePath;
65
64
  }
65
+ /** Remove a git worktree and clean up the .git/info/exclude entry. */
66
66
  export function removeWorktree(projectPath, worktreePath) {
67
67
  try {
68
68
  git(projectPath, ['worktree', 'remove', worktreePath, '--force']);
@@ -73,6 +73,7 @@ export function removeWorktree(projectPath, worktreePath) {
73
73
  }
74
74
  removeFromExclude(projectPath, worktreePath);
75
75
  }
76
+ /** List all git worktrees for a repository by parsing `git worktree list --porcelain`. */
76
77
  export function listWorktrees(projectPath) {
77
78
  const output = git(projectPath, ['worktree', 'list', '--porcelain']);
78
79
  const worktrees = [];
@@ -106,6 +107,7 @@ export function listWorktrees(projectPath) {
106
107
  }
107
108
  return worktrees;
108
109
  }
110
+ /** Check whether a worktree for the given branch already exists. */
109
111
  export function worktreeExists(projectPath, branchName) {
110
112
  try {
111
113
  const worktrees = listWorktrees(projectPath);
@@ -6,9 +6,11 @@ const execFileAsync = promisify(execFileCb);
6
6
  function git(repoPath, args) {
7
7
  return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
8
8
  }
9
+ /** Return the name of the currently checked-out branch. */
9
10
  export function getCurrentBranch(repoPath) {
10
11
  return git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
11
12
  }
13
+ /** List all local branch names in the repository. */
12
14
  export function listBranches(repoPath) {
13
15
  const output = git(repoPath, ['branch', '--format=%(refname:short)']);
14
16
  return output
@@ -16,20 +18,19 @@ export function listBranches(repoPath) {
16
18
  .map((b) => b.trim())
17
19
  .filter(Boolean);
18
20
  }
21
+ /** Thrown when attempting to create a branch that already exists. */
19
22
  export class BranchAlreadyExistsError extends Error {
20
23
  constructor(branchName) {
21
24
  super(`Branch '${branchName}' already exists`);
22
25
  this.name = 'BranchAlreadyExistsError';
23
26
  }
24
27
  }
25
- /**
26
- * M3: Shared utility to detect "branch already exists" git error messages
27
- * across different locales (English, French, Russian).
28
- */
28
+ /** Detect "branch already exists" git error messages across locales (EN, FR, RU). */
29
29
  export function isGitBranchExistsError(message) {
30
30
  const lower = message.toLowerCase();
31
31
  return lower.includes('already exists') || lower.includes('existe') || lower.includes('существует');
32
32
  }
33
+ /** Create a new local branch from the given source branch. */
33
34
  export function createBranch(repoPath, branchName, sourceBranch) {
34
35
  try {
35
36
  git(repoPath, ['branch', branchName, sourceBranch]);
@@ -42,6 +43,7 @@ export function createBranch(repoPath, branchName, sourceBranch) {
42
43
  throw new Error(`Failed to create branch '${branchName}' from '${sourceBranch}': ${message}`);
43
44
  }
44
45
  }
46
+ /** Return shortstat diff stats for staged (cached) changes. */
45
47
  export function getDiffStats(repoPath) {
46
48
  try {
47
49
  const output = git(repoPath, ['diff', '--cached', '--shortstat']);
@@ -64,6 +66,7 @@ function parseDiffShortstat(output) {
64
66
  deletions: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
65
67
  };
66
68
  }
69
+ /** List remote-tracking branch names. Returns empty array on failure. */
67
70
  export function listRemoteBranches(repoPath) {
68
71
  try {
69
72
  const output = git(repoPath, ['branch', '-r', '--format=%(refname:short)']);
@@ -76,6 +79,7 @@ export function listRemoteBranches(repoPath) {
76
79
  return [];
77
80
  }
78
81
  }
82
+ /** Force-delete a local branch (`git branch -D`). */
79
83
  export function deleteLocalBranch(repoPath, branchName) {
80
84
  try {
81
85
  git(repoPath, ['branch', '-D', branchName]);
@@ -85,6 +89,7 @@ export function deleteLocalBranch(repoPath, branchName) {
85
89
  throw new Error(`Failed to delete local branch '${branchName}': ${message}`);
86
90
  }
87
91
  }
92
+ /** Delete a branch on the remote (`git push --delete`). */
88
93
  export function deleteRemoteBranch(repoPath, branchName, remote = 'origin') {
89
94
  try {
90
95
  git(repoPath, ['push', remote, '--delete', branchName]);
@@ -94,6 +99,7 @@ export function deleteRemoteBranch(repoPath, branchName, remote = 'origin') {
94
99
  throw new Error(`Failed to delete remote branch '${remote}/${branchName}': ${message}`);
95
100
  }
96
101
  }
102
+ /** Push a branch to the remote with upstream tracking (`git push -u`). */
97
103
  export function pushBranch(repoPath, branchName, remote = 'origin') {
98
104
  try {
99
105
  git(repoPath, ['push', '-u', remote, branchName]);
@@ -119,6 +125,7 @@ function resolveBase(repoPath, base) {
119
125
  }
120
126
  }
121
127
  }
128
+ /** Count commits between base and head (`git rev-list --count`). Returns 0 on failure. */
122
129
  export function getCommitCount(repoPath, base, head) {
123
130
  try {
124
131
  const ref = resolveBase(repoPath, base);
@@ -129,6 +136,7 @@ export function getCommitCount(repoPath, base, head) {
129
136
  return 0;
130
137
  }
131
138
  }
139
+ /** Return structured diff shortstat between two refs (three-dot merge base). */
132
140
  export function getStructuredDiffStatsBetween(repoPath, base, head) {
133
141
  try {
134
142
  const ref = resolveBase(repoPath, base);
@@ -139,6 +147,7 @@ export function getStructuredDiffStatsBetween(repoPath, base, head) {
139
147
  return { filesChanged: 0, insertions: 0, deletions: 0 };
140
148
  }
141
149
  }
150
+ /** Return a formatted list of commit subjects between base and head. */
142
151
  export function getCommitsBetween(repoPath, base, head) {
143
152
  try {
144
153
  const ref = resolveBase(repoPath, base);
@@ -148,6 +157,7 @@ export function getCommitsBetween(repoPath, base, head) {
148
157
  return '';
149
158
  }
150
159
  }
160
+ /** Get the GitHub PR URL for a branch using `gh pr view`. Returns null if no PR exists. */
151
161
  export function getPrUrl(repoPath, branchName) {
152
162
  try {
153
163
  return (execFileSync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
@@ -159,6 +169,7 @@ export function getPrUrl(repoPath, branchName) {
159
169
  return null;
160
170
  }
161
171
  }
172
+ /** Get the state and URL of the PR for a branch. Returns null if no PR exists. */
162
173
  export function getPrStatus(repoPath, branchName) {
163
174
  try {
164
175
  const raw = execFileSync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
@@ -248,6 +259,7 @@ export function getFileContent(repoPath, filePath) {
248
259
  return null;
249
260
  }
250
261
  }
262
+ /** Parse `git status --porcelain` into counts of staged, modified, and untracked files. */
251
263
  export function getWorkingTreeStatus(repoPath) {
252
264
  try {
253
265
  const output = git(repoPath, ['status', '--porcelain']);
@@ -288,6 +300,7 @@ export function getUnpushedCount(repoPath) {
288
300
  return -1; // no upstream
289
301
  }
290
302
  }
303
+ /** Return raw `git diff --shortstat` output between two refs (three-dot). */
291
304
  export function getDiffStatsBetween(repoPath, base, head) {
292
305
  try {
293
306
  return git(repoPath, ['diff', '--shortstat', `${base}...${head}`]);
@@ -298,7 +311,7 @@ export function getDiffStatsBetween(repoPath, base, head) {
298
311
  }
299
312
  // ── Async versions ───────────────────────────────────────────────────────────
300
313
  // Non-blocking alternatives for hot paths (pr-watcher, route handlers).
301
- // The sync versions above are kept for callers that haven't migrated yet.
314
+ /** Async version of getPrUrl. Returns null if no PR exists. */
302
315
  export async function getPrUrlAsync(repoPath, branchName) {
303
316
  try {
304
317
  const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
@@ -311,6 +324,7 @@ export async function getPrUrlAsync(repoPath, branchName) {
311
324
  return null;
312
325
  }
313
326
  }
327
+ /** Async version of getPrStatus. Returns null if no PR exists. */
314
328
  export async function getPrStatusAsync(repoPath, branchName) {
315
329
  try {
316
330
  const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
@@ -1,16 +1,22 @@
1
+ /** In-memory map of workspace-id to spawned child process. */
1
2
  const trackedProcesses = new Map();
3
+ /** Register a child process under the given workspace id. */
2
4
  export function registerProcess(id, proc) {
3
5
  trackedProcesses.set(id, proc);
4
6
  }
7
+ /** Remove a child process from tracking without killing it. */
5
8
  export function unregisterProcess(id) {
6
9
  trackedProcesses.delete(id);
7
10
  }
11
+ /** Retrieve the tracked child process for a workspace, if any. */
8
12
  export function getProcess(id) {
9
13
  return trackedProcesses.get(id);
10
14
  }
15
+ /** Return the number of currently tracked processes. */
11
16
  export function getTrackedCount() {
12
17
  return trackedProcesses.size;
13
18
  }
19
+ /** Send SIGTERM to all tracked processes, escalating to SIGKILL after 5 seconds. */
14
20
  export function killAll() {
15
21
  const procs = [...trackedProcesses.values()];
16
22
  trackedProcesses.clear();
@@ -37,6 +43,7 @@ export function killAll() {
37
43
  t.unref?.();
38
44
  });
39
45
  }
46
+ /** Register a process-exit handler that kills all tracked child processes. */
40
47
  export function initProcessCleanup() {
41
48
  process.on('exit', killAll);
42
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -0,0 +1 @@
1
+ .activity-feed[data-v-b51e6f0f]{flex-direction:column;gap:4px;display:flex;position:relative;overflow:hidden auto}.af-item[data-v-b51e6f0f]{word-break:break-word;overflow-wrap:break-word;flex-shrink:0;padding:6px 10px;overflow-x:hidden}.af-time[data-v-b51e6f0f]{color:#555;flex-shrink:0;font-size:10px}.af-tool-label[data-v-b51e6f0f]{font-family:Roboto Mono,monospace;font-size:11px}.af-tool-desc[data-v-b51e6f0f]{text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.af-ask-option-item[data-v-b51e6f0f]{padding:2px 0}.af-tool-args[data-v-b51e6f0f]{background-color:#ffffff0a;padding:6px 8px;overflow-x:auto}.af-args-pre[data-v-b51e6f0f]{color:#888;white-space:pre-wrap;word-break:break-word;margin:0;font-family:Roboto Mono,monospace;font-size:10px}.af-file-change[data-v-b51e6f0f]{background:#ffffff08;border:1px solid #ffffff0f;border-radius:6px;padding:6px 8px}.af-file-header[data-v-b51e6f0f]{font-family:Roboto Mono,monospace;font-size:11px}.af-lang-badge[data-v-b51e6f0f]{text-align:center;background:#ffffff0f;border-radius:3px;min-width:20px;padding:1px 4px;font-family:Roboto Mono,monospace;font-size:9px;font-weight:700}.af-file-path[data-v-b51e6f0f]{max-width:70%;font-size:11px}.af-diff-stats[data-v-b51e6f0f]{white-space:nowrap;font-family:Roboto Mono,monospace;font-size:10px}.af-diff-body[data-v-b51e6f0f]{background:#0003;border-radius:4px;max-height:300px;padding:4px 0;overflow:auto}.af-diff-line[data-v-b51e6f0f]{white-space:pre;min-width:-moz-fit-content;min-width:fit-content;padding:0 8px;font-family:Roboto Mono,monospace;font-size:10px;line-height:1.5}.af-diff-sign[data-v-b51e6f0f]{-webkit-user-select:none;user-select:none;width:12px;display:inline-block}.af-diff-del[data-v-b51e6f0f]{color:#f85149;background:#f851491a}.af-diff-add[data-v-b51e6f0f]{color:#3fb950;background:#3fb9501a}.af-item--text[data-v-b51e6f0f]{background-color:#1a2a3a;border-left:3px solid #3b82f6}.af-item--user[data-v-b51e6f0f]{background-color:#1a2a1a;border-left:3px solid #22c55e}.af-item--prompt[data-v-b51e6f0f]{background-color:#1a1a2e;border-left:3px solid #6c63ff}.af-text-content[data-v-b51e6f0f]{color:#d0d0d0;word-break:break-word;line-height:1.5}.af-markdown[data-v-b51e6f0f] p{margin:0 0 8px}.af-markdown[data-v-b51e6f0f] p:last-child{margin-bottom:0}.af-markdown[data-v-b51e6f0f] h1,.af-markdown[data-v-b51e6f0f] h2,.af-markdown[data-v-b51e6f0f] h3{color:#e0e0e0;margin:12px 0 6px}.af-markdown[data-v-b51e6f0f] h1{font-size:16px}.af-markdown[data-v-b51e6f0f] h2{font-size:14px}.af-markdown[data-v-b51e6f0f] h3{font-size:13px}.af-markdown[data-v-b51e6f0f] ul,.af-markdown[data-v-b51e6f0f] ol{margin:4px 0;padding-left:20px}.af-markdown[data-v-b51e6f0f] li{margin:2px 0}.af-markdown[data-v-b51e6f0f] code{background-color:#ffffff14;border-radius:3px;padding:1px 4px;font-family:Roboto Mono,monospace;font-size:11px}.af-markdown[data-v-b51e6f0f] pre{background-color:#0000004d;border-radius:4px;margin:6px 0;padding:8px;overflow-x:auto}.af-markdown[data-v-b51e6f0f] pre code{background:0 0;padding:0}.af-markdown[data-v-b51e6f0f] strong{color:#fff}.af-markdown[data-v-b51e6f0f] a{color:#6c63ff}.af-markdown[data-v-b51e6f0f] blockquote{color:#aaa;border-left:3px solid #6c63ff;margin:6px 0;padding:4px 12px}.af-markdown[data-v-b51e6f0f] table{border-collapse:collapse;margin:6px 0;font-size:11px}.af-markdown[data-v-b51e6f0f] table th,.af-markdown[data-v-b51e6f0f] table td{border:1px solid #2a2a4a;padding:4px 8px}.af-markdown[data-v-b51e6f0f] table th{background-color:#ffffff0d}.af-item--system[data-v-b51e6f0f]{background-color:#2a2a1a;border-left:3px solid #f59e0b}.af-system-details[data-v-b51e6f0f]{background-color:#ffffff0a;padding:6px 8px;overflow-x:auto}.af-system-content[data-v-b51e6f0f]{white-space:pre-wrap}.af-item--error[data-v-b51e6f0f]{background-color:#2a1a1a;border-left:3px solid #ef4444}.af-item--raw[data-v-b51e6f0f]{white-space:pre-wrap;font-family:Roboto Mono,monospace}.scroll-buttons[data-v-b51e6f0f]{align-self:flex-end;gap:6px;margin-right:8px;display:flex;position:sticky;bottom:8px}.scroll-btn[data-v-b51e6f0f]{opacity:.7;transition:opacity .15s}.scroll-btn[data-v-b51e6f0f]:hover{opacity:1}