@loicngr/kobo 1.6.11 → 1.6.13

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 (80) hide show
  1. package/README.md +10 -6
  2. package/dist/mcp-server/kobo-tasks-handlers.js +2 -2
  3. package/dist/server/db/migrations.js +31 -0
  4. package/dist/server/db/schema.js +11 -0
  5. package/dist/server/index.js +32 -5
  6. package/dist/server/routes/documents.js +2 -2
  7. package/dist/server/routes/git.js +21 -0
  8. package/dist/server/routes/health.js +2 -2
  9. package/dist/server/routes/images.js +3 -3
  10. package/dist/server/routes/usage.js +18 -0
  11. package/dist/server/routes/workspaces.js +231 -146
  12. package/dist/server/services/agent/engines/claude-code/args-builder.js +2 -0
  13. package/dist/server/services/agent/orchestrator.js +1 -1
  14. package/dist/server/services/auto-loop-service.js +22 -4
  15. package/dist/server/services/dev-server-service.js +2 -5
  16. package/dist/server/services/notion-service.js +15 -3
  17. package/dist/server/services/settings-service.js +18 -2
  18. package/dist/server/services/usage/db.js +29 -0
  19. package/dist/server/services/usage/index.js +2 -0
  20. package/dist/server/services/usage/poller.js +52 -0
  21. package/dist/server/services/usage/providers/claude-code.js +93 -0
  22. package/dist/server/services/usage/types.js +1 -0
  23. package/dist/server/services/wakeup-service.js +2 -2
  24. package/dist/server/services/websocket-service.js +14 -0
  25. package/dist/server/services/workspace-service.js +29 -3
  26. package/dist/server/services/worktree-service.js +50 -0
  27. package/dist/server/utils/mcp-client.js +7 -0
  28. package/dist/shared/auto-loop-prompts.js +46 -8
  29. package/package.json +1 -1
  30. package/src/client/dist/spa/assets/{ActivityFeed-Cv2cHAob.js → ActivityFeed-BsY3-q5d.js} +1 -1
  31. package/src/client/dist/spa/assets/CreatePage-Cdhkkx-X.js +2 -0
  32. package/src/client/dist/spa/assets/CreatePage-PRvhol1N.css +1 -0
  33. package/src/client/dist/spa/assets/{DiffViewer-xp8A1R-2.js → DiffViewer-DXcoEtVq.js} +2 -2
  34. package/src/client/dist/spa/assets/{HealthPage-iPEmaIxf.js → HealthPage-BSyGqDRu.js} +1 -1
  35. package/src/client/dist/spa/assets/MainLayout-D2SfvksB.css +1 -0
  36. package/src/client/dist/spa/assets/{MainLayout-CLSGgDp_.js → MainLayout-EYaLqjJx.js} +17 -17
  37. package/src/client/dist/spa/assets/{SearchPage-C_Z_-mPY.js → SearchPage-Bgx02GOH.js} +1 -1
  38. package/src/client/dist/spa/assets/SettingsPage-BTSOovDV.js +1 -0
  39. package/src/client/dist/spa/assets/SettingsPage-CwLELxfl.css +1 -0
  40. package/src/client/dist/spa/assets/WorkspacePage-C5MZx1sZ.css +1 -0
  41. package/src/client/dist/spa/assets/WorkspacePage-C8dJWu-n.js +4 -0
  42. package/src/client/dist/spa/assets/{build-path-tree-BAbslBF6.js → build-path-tree-D-2LpB2J.js} +1 -1
  43. package/src/client/dist/spa/assets/{cssMode-COLGl5q-.js → cssMode-DVBmJp-B.js} +1 -1
  44. package/src/client/dist/spa/assets/{documents-BlJv_G6j.js → documents-Ck8VwvpQ.js} +1 -1
  45. package/src/client/dist/spa/assets/{editor.api-CmWAkEBP.js → editor.api-DgbPJaK4.js} +1 -1
  46. package/src/client/dist/spa/assets/{editor.main-B50pvEsj.js → editor.main-BqqoRfAU.js} +3 -3
  47. package/src/client/dist/spa/assets/{expand-template-CVF0qYFz.js → expand-template-bkCTc78P.js} +1 -1
  48. package/src/client/dist/spa/assets/{freemarker2-x6p1HdGv.js → freemarker2-CgaW0Q0y.js} +1 -1
  49. package/src/client/dist/spa/assets/{handlebars-DVdpHx-F.js → handlebars-BSs5PdXe.js} +1 -1
  50. package/src/client/dist/spa/assets/{html-CVBXIMk9.js → html-C9wlJaMs.js} +1 -1
  51. package/src/client/dist/spa/assets/{htmlMode-bcy_B_7M.js → htmlMode-DaRssGJk.js} +1 -1
  52. package/src/client/dist/spa/assets/i18n-BSNIShFg.js +1 -0
  53. package/src/client/dist/spa/assets/index-odgA9x8A.js +2 -0
  54. package/src/client/dist/spa/assets/{javascript-C8NntD-t.js → javascript-D0VYhsc-.js} +1 -1
  55. package/src/client/dist/spa/assets/{jsonMode-CKmO44kP.js → jsonMode-B57EaUNS.js} +1 -1
  56. package/src/client/dist/spa/assets/kobo-commands-D-9dbM70.js +11 -0
  57. package/src/client/dist/spa/assets/{liquid-JGbytnvM.js → liquid-gP2gg7sw.js} +1 -1
  58. package/src/client/dist/spa/assets/{mdx-8xDuI4Ra.js → mdx-HhXcZn_S.js} +1 -1
  59. package/src/client/dist/spa/assets/{models-IFgNVQuG.js → models-CJC61gWE.js} +1 -1
  60. package/src/client/dist/spa/assets/{monaco.contribution-DxgwWnvV.js → monaco.contribution-ChJg8bwd.js} +2 -2
  61. package/src/client/dist/spa/assets/{python-DntkvJJn.js → python-DM6FfMV3.js} +1 -1
  62. package/src/client/dist/spa/assets/{razor-CBMu7MSu.js → razor-XifsxhTG.js} +1 -1
  63. package/src/client/dist/spa/assets/stats-C3n1k51k.js +1 -0
  64. package/src/client/dist/spa/assets/{tsMode-qUgDyCdk.js → tsMode-B8gurPqG.js} +1 -1
  65. package/src/client/dist/spa/assets/{typescript-Bp6APJ3s.js → typescript-CZKTCOjl.js} +1 -1
  66. package/src/client/dist/spa/assets/{xml-D1_1t5sz.js → xml-CtZPkb7Q.js} +1 -1
  67. package/src/client/dist/spa/assets/{yaml-T38tRjC8.js → yaml-D5IEE5M-.js} +1 -1
  68. package/src/client/dist/spa/index.html +1 -1
  69. package/src/mcp-server/kobo-tasks-handlers.ts +3 -2
  70. package/src/client/dist/spa/assets/CreatePage-DrGARGo5.js +0 -2
  71. package/src/client/dist/spa/assets/CreatePage-d0Qp-PnO.css +0 -1
  72. package/src/client/dist/spa/assets/MainLayout-CDR4Le5c.css +0 -1
  73. package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +0 -1
  74. package/src/client/dist/spa/assets/SettingsPage-DETFZXCZ.js +0 -1
  75. package/src/client/dist/spa/assets/WorkspacePage-BAkCj4gZ.js +0 -4
  76. package/src/client/dist/spa/assets/WorkspacePage-Bo1GW3wo.css +0 -1
  77. package/src/client/dist/spa/assets/i18n-DncqzfKK.js +0 -1
  78. package/src/client/dist/spa/assets/index-Dl8rTFls.js +0 -2
  79. package/src/client/dist/spa/assets/kobo-commands-30GNdCpd.js +0 -10
  80. package/src/client/dist/spa/assets/rate-limit-labels-BaD9dQtl.js +0 -1
@@ -1,14 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { buildE2eIterationBlock } from '../../shared/auto-loop-prompts.js';
3
4
  import { getDb } from '../db/index.js';
4
5
  import * as orchestrator from './agent/orchestrator.js';
6
+ import * as settingsService from './settings-service.js';
5
7
  import { emit, emitEphemeral } from './websocket-service.js';
6
8
  import { listTasks } from './workspace-service.js';
7
9
  const NO_PROGRESS_STALL_THRESHOLD = 3;
8
10
  function getRow(workspaceId) {
9
11
  const db = getDb();
10
12
  const row = db
11
- .prepare(`SELECT id, project_path, working_branch, model, permission_mode, reasoning_effort,
13
+ .prepare(`SELECT id, project_path, working_branch, worktree_path, model, permission_mode, reasoning_effort,
12
14
  status, auto_loop, auto_loop_ready, no_progress_streak, archived_at
13
15
  FROM workspaces WHERE id = ?`)
14
16
  .get(workspaceId);
@@ -137,6 +139,13 @@ export function rehydrate() {
137
139
  try {
138
140
  if (orchestrator.hasController(id))
139
141
  continue;
142
+ // Workspaces still in grooming (ready=0) have their session killed by
143
+ // the server reload. Don't disable — the user can re-trigger grooming
144
+ // manually. Auto-disable on missing pending tasks would also fire here
145
+ // if the agent hadn't yet seeded any task before the reload.
146
+ const row = getRow(id);
147
+ if (row?.auto_loop_ready !== 1)
148
+ continue;
140
149
  if (countPendingTasks(id) === 0) {
141
150
  disable(id, 'completed');
142
151
  continue;
@@ -163,7 +172,7 @@ Current pending task (highest priority, non-acceptance-criterion first):
163
172
  - Task ID: {taskId}
164
173
  - Title: {taskTitle}
165
174
  - Is acceptance criterion: {isAcceptanceCriterion}
166
-
175
+ {e2eBlock}
167
176
  Your job this iteration:
168
177
  1. Read \`kobo__list_tasks\` to see all tasks and the big picture.
169
178
  2. Implement the SINGLE task above and nothing else. Do not pick a different task.
@@ -216,11 +225,20 @@ function spawnNextIteration(workspaceId, opts = {}) {
216
225
  return;
217
226
  }
218
227
  const iterationNumber = computeIterationNumber(workspaceId);
228
+ // E2E iteration block: only injected for tasks whose title starts with the
229
+ // exact `[E2E] ` prefix (case-sensitive, trailing space required) AND when
230
+ // the project has an E2E framework configured. Empty string otherwise so
231
+ // the placeholder collapses cleanly in PROMPT_TEMPLATE.
232
+ const projectSettings = settingsService.getProjectSettings(row.project_path);
233
+ const e2eSettings = projectSettings?.e2e ?? { framework: '', skill: '', prompt: '' };
234
+ const isE2eTask = task.title.startsWith('[E2E] ');
235
+ const e2eBlock = isE2eTask && e2eSettings.framework ? buildE2eIterationBlock(e2eSettings) : '';
219
236
  const prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
220
237
  .replaceAll('{taskId}', task.id)
221
238
  .replaceAll('{taskTitle}', task.title)
222
- .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion));
223
- const worktreePath = path.join(row.project_path, '.worktrees', row.working_branch);
239
+ .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
240
+ .replaceAll('{e2eBlock}', e2eBlock);
241
+ const worktreePath = row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch);
224
242
  // Auto-loop iterations always run in auto-accept mode. Plan mode blocks MCP
225
243
  // tools (kobo__mark_task_done, etc.) and Edit/Write/Bash — everything the
226
244
  // iteration needs — so honoring a 'plan' setting here would deadlock the loop.
@@ -5,9 +5,6 @@ import { getProjectSettings } from './settings-service.js';
5
5
  import { emitEphemeral } from './websocket-service.js';
6
6
  import { getWorkspace, updateDevServerStatus } from './workspace-service.js';
7
7
  // ── Helpers ───────────────────────────────────────────────────────────────────
8
- function getWorktreePath(projectPath, workingBranch) {
9
- return path.join(projectPath, '.worktrees', workingBranch);
10
- }
11
8
  /** Build a clean env for child processes, stripping Kobo-specific variables. */
12
9
  function cleanEnv() {
13
10
  const { PORT, SERVER_PORT, ...rest } = process.env;
@@ -166,7 +163,7 @@ export function startDevServer(workspaceId) {
166
163
  }
167
164
  const instanceName = sanitizeBranchName(workspace.workingBranch);
168
165
  // Execute as bash script (supports multi-line scripts)
169
- const worktreePath = getWorktreePath(workspace.projectPath, workspace.workingBranch);
166
+ const worktreePath = workspace.worktreePath;
170
167
  const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
171
168
  const proc = spawn('bash', ['-c', settings.devServer.startCommand], {
172
169
  cwd,
@@ -232,7 +229,7 @@ export function stopDevServer(workspaceId) {
232
229
  }
233
230
  const config = resolveInstance(workspace.projectPath, workspace.workingBranch);
234
231
  const instanceName = config?.instanceName ?? sanitizeBranchName(workspace.workingBranch);
235
- const worktreePath = getWorktreePath(workspace.projectPath, workspace.workingBranch);
232
+ const worktreePath = workspace.worktreePath;
236
233
  const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
237
234
  // Kill tracked process first (covers Node servers and any spawned process)
238
235
  const tracked = trackedProcesses.get(workspaceId);
@@ -6,14 +6,28 @@ const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|Wh
6
6
  // NOTE: `Feature`/`Fonctionnalité` are top-level containers, not a new scenario,
7
7
  // so they stay attached to the first scenario rather than triggering a split.
8
8
  const SCENARIO_START_PATTERN = /^(Scénario|Scenario)/i;
9
+ function formatAsUuid(raw32Hex) {
10
+ return `${raw32Hex.slice(0, 8)}-${raw32Hex.slice(8, 12)}-${raw32Hex.slice(12, 16)}-${raw32Hex.slice(16, 20)}-${raw32Hex.slice(20)}`;
11
+ }
9
12
  /**
10
13
  * Parse a Notion URL and extract the page_id in UUID format (with dashes).
11
14
  * Handles:
12
15
  * https://www.notion.so/workspace/Title-<32hexChars>
13
16
  * https://www.notion.so/workspace/<32hexChars>
14
17
  * https://www.notion.so/<32hexChars>
18
+ * https://www.notion.so/workspace/<parentId>?p=<32hexChars>&pm=s (side-peek)
19
+ * https://www.notion.so/workspace/<dbId>?v=<viewId>&p=<32hexChars> (database peek)
20
+ *
21
+ * When the URL embeds `?p=<32hex>` the path component is the parent / database
22
+ * ID and the actual page being viewed is in the query parameter — that takes
23
+ * precedence over the path.
15
24
  */
16
25
  export function parseNotionUrl(url) {
26
+ // Side-peek / database pages embed the real page ID in `?p=<32hex>`.
27
+ const pParamMatch = url.match(/[?&]p=([0-9a-f]{32})(?:[&#]|$)/i);
28
+ if (pParamMatch) {
29
+ return formatAsUuid(pParamMatch[1]);
30
+ }
17
31
  // Strip query string and fragment
18
32
  const cleanUrl = url.split('?')[0].split('#')[0];
19
33
  // The page ID is always the last 32 hex characters (no dashes) at the end of the path
@@ -26,9 +40,7 @@ export function parseNotionUrl(url) {
26
40
  }
27
41
  throw new Error(`Could not extract page ID from Notion URL: ${url}`);
28
42
  }
29
- const raw = match[1];
30
- // Convert 32 hex chars to UUID format: 8-4-4-4-12
31
- return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
43
+ return formatAsUuid(match[1]);
32
44
  }
33
45
  /**
34
46
  * Read the Notion token from the user's Claude Code config as a fallback.
@@ -228,6 +228,11 @@ function defaultProjectSettings(projectPath) {
228
228
  startCommand: '',
229
229
  stopCommand: '',
230
230
  },
231
+ e2e: {
232
+ framework: '',
233
+ skill: '',
234
+ prompt: '',
235
+ },
231
236
  };
232
237
  }
233
238
  function pickKnownKeys(data, allowedKeys) {
@@ -434,7 +439,7 @@ export function updateGlobalSettings(data) {
434
439
  writeSettings(settings, { backup: true });
435
440
  return settings.global;
436
441
  }
437
- /** Create or update project-specific settings. Merges devServer fields on update. */
442
+ /** Create or update project-specific settings. Merges devServer and e2e fields on update. */
438
443
  export function upsertProject(projectPath, data) {
439
444
  const allowedProjectKeys = [
440
445
  'displayName',
@@ -445,23 +450,31 @@ export function upsertProject(projectPath, data) {
445
450
  'gitConventions',
446
451
  'setupScript',
447
452
  'devServer',
453
+ 'e2e',
448
454
  ];
449
455
  const allowedDevServerKeys = ['startCommand', 'stopCommand'];
456
+ const allowedE2eKeys = ['framework', 'skill', 'prompt'];
450
457
  const filtered = pickKnownKeys(data, allowedProjectKeys);
451
458
  if (filtered.devServer) {
452
459
  filtered.devServer = pickKnownKeys(filtered.devServer, allowedDevServerKeys);
453
460
  }
461
+ if (filtered.e2e) {
462
+ filtered.e2e = pickKnownKeys(filtered.e2e, allowedE2eKeys);
463
+ }
454
464
  const settings = readSettings();
455
465
  const idx = settings.projects.findIndex((p) => p.path === projectPath);
456
466
  if (idx >= 0) {
457
- // Update existing project — merge devServer separately to allow partial updates
467
+ // Update existing project — merge devServer and e2e separately to allow partial updates
458
468
  const existing = settings.projects[idx];
459
469
  const updatedDevServer = filtered.devServer ? { ...existing.devServer, ...filtered.devServer } : existing.devServer;
470
+ const existingE2e = existing.e2e ?? defaultProjectSettings(projectPath).e2e;
471
+ const updatedE2e = filtered.e2e ? { ...existingE2e, ...filtered.e2e } : existingE2e;
460
472
  settings.projects[idx] = {
461
473
  ...existing,
462
474
  ...filtered,
463
475
  path: projectPath,
464
476
  devServer: updatedDevServer,
477
+ e2e: updatedE2e,
465
478
  };
466
479
  }
467
480
  else {
@@ -474,6 +487,9 @@ export function upsertProject(projectPath, data) {
474
487
  if (filtered.devServer) {
475
488
  newProject.devServer = { ...defaultProjectSettings(projectPath).devServer, ...filtered.devServer };
476
489
  }
490
+ if (filtered.e2e) {
491
+ newProject.e2e = { ...defaultProjectSettings(projectPath).e2e, ...filtered.e2e };
492
+ }
477
493
  settings.projects.push(newProject);
478
494
  }
479
495
  writeSettings(settings, { backup: true });
@@ -0,0 +1,29 @@
1
+ import { getDb } from '../../db/index.js';
2
+ function rowToSnapshot(row) {
3
+ const buckets = JSON.parse(row.buckets_json);
4
+ const snap = {
5
+ providerId: row.provider_id,
6
+ status: row.status,
7
+ buckets,
8
+ fetchedAt: row.fetched_at,
9
+ };
10
+ if (row.error_message !== null) {
11
+ snap.errorMessage = row.error_message;
12
+ }
13
+ return snap;
14
+ }
15
+ export function upsertUsageSnapshot(snap) {
16
+ const db = getDb();
17
+ db.prepare(`INSERT INTO usage_snapshots (provider_id, status, error_message, buckets_json, fetched_at)
18
+ VALUES (?, ?, ?, ?, ?)
19
+ ON CONFLICT(provider_id) DO UPDATE SET
20
+ status = excluded.status,
21
+ error_message = excluded.error_message,
22
+ buckets_json = excluded.buckets_json,
23
+ fetched_at = excluded.fetched_at`).run(snap.providerId, snap.status, snap.errorMessage ?? null, JSON.stringify(snap.buckets), snap.fetchedAt);
24
+ }
25
+ export function getAllPersistedSnapshots() {
26
+ const db = getDb();
27
+ const rows = db.prepare('SELECT * FROM usage_snapshots ORDER BY provider_id').all();
28
+ return rows.map(rowToSnapshot);
29
+ }
@@ -0,0 +1,2 @@
1
+ export { getAllPersistedSnapshots } from './db.js';
2
+ export { refreshNow, startUsagePoller, stopUsagePoller } from './poller.js';
@@ -0,0 +1,52 @@
1
+ import { broadcastAll } from '../websocket-service.js';
2
+ import { upsertUsageSnapshot } from './db.js';
3
+ import { createClaudeCodeProvider } from './providers/claude-code.js';
4
+ export const POLL_INTERVAL_MS = 60_000;
5
+ const DEFAULT_PROVIDERS = [createClaudeCodeProvider()];
6
+ let providers = DEFAULT_PROVIDERS;
7
+ let intervalHandle = null;
8
+ function persistAndBroadcast(snap) {
9
+ upsertUsageSnapshot(snap);
10
+ broadcastAll('usage:snapshot', { providerId: snap.providerId, snapshot: snap });
11
+ }
12
+ async function tick() {
13
+ for (const provider of providers) {
14
+ try {
15
+ if (!(await provider.isAvailable()))
16
+ continue;
17
+ const snap = await provider.fetchSnapshot();
18
+ persistAndBroadcast(snap);
19
+ }
20
+ catch (err) {
21
+ console.error('[usage-poller] tick failed for provider', provider.id, err);
22
+ }
23
+ }
24
+ }
25
+ export function startUsagePoller() {
26
+ if (intervalHandle !== null)
27
+ return;
28
+ void tick();
29
+ intervalHandle = setInterval(() => {
30
+ void tick();
31
+ }, POLL_INTERVAL_MS);
32
+ }
33
+ export function stopUsagePoller() {
34
+ if (intervalHandle === null)
35
+ return;
36
+ clearInterval(intervalHandle);
37
+ intervalHandle = null;
38
+ }
39
+ // Bypasses `isAvailable()` — a manual refresh always returns a snapshot,
40
+ // even an `unauthenticated` one, so the UI gets feedback on the click.
41
+ export async function refreshNow(providerId) {
42
+ const provider = providers.find((p) => p.id === providerId);
43
+ if (!provider)
44
+ return null;
45
+ const snap = await provider.fetchSnapshot();
46
+ persistAndBroadcast(snap);
47
+ return snap;
48
+ }
49
+ // Test seam — pass `null` to restore the default provider list.
50
+ export function _setProvidersForTest(list) {
51
+ providers = list ?? DEFAULT_PROVIDERS;
52
+ }
@@ -0,0 +1,93 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ const API_URL = 'https://api.anthropic.com/api/oauth/usage';
5
+ const BETA_HEADER = 'oauth-2025-04-20';
6
+ const FETCH_TIMEOUT_MS = 10_000;
7
+ function credentialsFilePath() {
8
+ const dir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude');
9
+ return path.join(dir, '.credentials.json');
10
+ }
11
+ async function readAccessToken() {
12
+ try {
13
+ const raw = await fs.readFile(credentialsFilePath(), 'utf8');
14
+ const parsed = JSON.parse(raw);
15
+ const token = parsed?.claudeAiOauth?.accessToken;
16
+ return typeof token === 'string' && token.length > 0 ? token : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function mapBucket(id, raw) {
23
+ const utilization = typeof raw?.utilization === 'number' ? raw.utilization : 0;
24
+ const resetsAt = typeof raw?.resets_at === 'string' ? raw.resets_at : undefined;
25
+ return {
26
+ id,
27
+ label: id,
28
+ usedPct: utilization,
29
+ resetsAt,
30
+ };
31
+ }
32
+ export function createClaudeCodeProvider() {
33
+ return {
34
+ id: 'claude-code',
35
+ displayName: 'Claude Code',
36
+ async isAvailable() {
37
+ return (await readAccessToken()) !== null;
38
+ },
39
+ async fetchSnapshot() {
40
+ const fetchedAt = new Date().toISOString();
41
+ const token = await readAccessToken();
42
+ if (!token) {
43
+ return {
44
+ providerId: 'claude-code',
45
+ status: 'unauthenticated',
46
+ buckets: [],
47
+ fetchedAt,
48
+ };
49
+ }
50
+ const controller = new AbortController();
51
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
52
+ try {
53
+ const res = await fetch(API_URL, {
54
+ headers: {
55
+ Authorization: `Bearer ${token}`,
56
+ 'anthropic-beta': BETA_HEADER,
57
+ },
58
+ signal: controller.signal,
59
+ });
60
+ if (!res.ok) {
61
+ return {
62
+ providerId: 'claude-code',
63
+ status: 'error',
64
+ errorMessage: `HTTP ${res.status}`,
65
+ buckets: [],
66
+ fetchedAt,
67
+ };
68
+ }
69
+ const body = (await res.json());
70
+ const buckets = [mapBucket('five_hour', body.five_hour), mapBucket('seven_day', body.seven_day)];
71
+ return {
72
+ providerId: 'claude-code',
73
+ status: 'ok',
74
+ buckets,
75
+ fetchedAt,
76
+ };
77
+ }
78
+ catch (err) {
79
+ const errorMessage = err instanceof Error ? err.message : String(err);
80
+ return {
81
+ providerId: 'claude-code',
82
+ status: 'error',
83
+ errorMessage,
84
+ buckets: [],
85
+ fetchedAt,
86
+ };
87
+ }
88
+ finally {
89
+ clearTimeout(timeout);
90
+ }
91
+ },
92
+ };
93
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -125,14 +125,14 @@ function fire(workspaceId) {
125
125
  return;
126
126
  }
127
127
  const wsRow = db
128
- .prepare(`SELECT project_path, working_branch, model, permission_mode, reasoning_effort
128
+ .prepare(`SELECT project_path, working_branch, worktree_path, model, permission_mode, reasoning_effort
129
129
  FROM workspaces WHERE id = ?`)
130
130
  .get(workspaceId);
131
131
  if (!wsRow) {
132
132
  emitEphemeral(workspaceId, 'wakeup:skipped', { reason: 'fire-failed' });
133
133
  return;
134
134
  }
135
- const worktreePath = path.join(wsRow.project_path, '.worktrees', wsRow.working_branch);
135
+ const worktreePath = wsRow.worktree_path ?? path.join(wsRow.project_path, '.worktrees', wsRow.working_branch);
136
136
  // Defensive: narrow `permission_mode` against the two known values rather
137
137
  // than trusting the DB column shape. Any unexpected value falls back to
138
138
  // the safer 'auto-accept'.
@@ -1,5 +1,6 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
+ import { getAllPersistedSnapshots } from './usage/db.js';
3
4
  // ── State ──────────────────────────────────────────────────────────────────────
4
5
  /** Maps each WS client to the set of workspaceIds they are subscribed to */
5
6
  const clients = new Map();
@@ -13,6 +14,19 @@ export function setMessageHandler(handler) {
13
14
  export function handleConnection(ws) {
14
15
  // Register client with empty subscription set
15
16
  clients.set(ws, new Set());
17
+ // Push the latest persisted snapshot per provider so the client renders
18
+ // immediately instead of waiting up to POLL_INTERVAL_MS for the next tick.
19
+ try {
20
+ for (const snap of getAllPersistedSnapshots()) {
21
+ ws.send(JSON.stringify({
22
+ type: 'usage:snapshot',
23
+ payload: { providerId: snap.providerId, snapshot: snap },
24
+ }));
25
+ }
26
+ }
27
+ catch (err) {
28
+ console.error('[ws] usage hydration failed:', err);
29
+ }
16
30
  ws.on('message', (data) => {
17
31
  let msg;
18
32
  try {
@@ -29,6 +29,7 @@ function mapWorkspace(row) {
29
29
  status: row.status,
30
30
  notionUrl: row.notion_url,
31
31
  notionPageId: row.notion_page_id,
32
+ sentryUrl: row.sentry_url,
32
33
  model: row.model,
33
34
  reasoningEffort: row.reasoning_effort ?? 'auto',
34
35
  permissionMode: (row.permission_mode ?? 'auto-accept'),
@@ -42,6 +43,8 @@ function mapWorkspace(row) {
42
43
  autoLoopReady: row.auto_loop_ready === 1,
43
44
  noProgressStreak: row.no_progress_streak ?? 0,
44
45
  permissionProfile: (row.permission_profile ?? 'bypass'),
46
+ worktreePath: row.worktree_path ?? '',
47
+ worktreeOwned: row.worktree_owned === 1,
45
48
  createdAt: row.created_at,
46
49
  updatedAt: row.updated_at,
47
50
  };
@@ -77,10 +80,15 @@ export function createWorkspace(data) {
77
80
  const db = getDb();
78
81
  const now = new Date().toISOString();
79
82
  const id = nanoid();
83
+ const computedWorktreePath = data.worktreePath ?? `${data.projectPath}/.worktrees/${data.workingBranch}`;
84
+ const owned = data.worktreeOwned ?? true;
80
85
  db.prepare(`
81
- INSERT INTO workspaces (id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, model, reasoning_effort, permission_mode, engine, created_at, updated_at)
82
- VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?)
83
- `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.model ?? 'claude-opus-4-7', data.reasoningEffort ?? 'auto', data.permissionMode ?? 'auto-accept', data.engine ?? 'claude-code', now, now);
86
+ INSERT INTO workspaces (
87
+ id, name, project_path, source_branch, working_branch, status,
88
+ notion_url, notion_page_id, sentry_url, worktree_path, worktree_owned,
89
+ model, reasoning_effort, permission_mode, engine, created_at, updated_at
90
+ ) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91
+ `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'claude-opus-4-7', data.reasoningEffort ?? 'auto', data.permissionMode ?? 'auto-accept', data.engine ?? 'claude-code', now, now);
84
92
  return getWorkspace(id);
85
93
  }
86
94
  /** Fetch a single workspace by ID, or null if not found. */
@@ -113,6 +121,12 @@ export function updateWorkspaceStatus(id, status) {
113
121
  if (!workspace) {
114
122
  throw new Error(`Workspace '${id}' not found`);
115
123
  }
124
+ // Self-transition is a no-op. Some failure paths fan out (e.g. engine emits
125
+ // an error event AND the process exits non-zero), and both try to mark the
126
+ // workspace 'error' — the second call shouldn't throw.
127
+ if (workspace.status === status) {
128
+ return workspace;
129
+ }
116
130
  const allowedTransitions = VALID_TRANSITIONS[workspace.status];
117
131
  if (!allowedTransitions.includes(status)) {
118
132
  throw new Error(`Invalid status transition from '${workspace.status}' to '${status}'. Allowed: ${allowedTransitions.join(', ')}`);
@@ -186,6 +200,18 @@ export function updateWorkingBranch(id, workingBranch) {
186
200
  }
187
201
  return getWorkspace(id);
188
202
  }
203
+ /** Update the on-disk worktree path. Used by rename / resync-branch on owned worktrees. */
204
+ export function updateWorktreePath(id, newPath) {
205
+ const db = getDb();
206
+ const now = new Date().toISOString();
207
+ const result = db
208
+ .prepare('UPDATE workspaces SET worktree_path = ?, updated_at = ? WHERE id = ?')
209
+ .run(newPath, now, id);
210
+ if (result.changes === 0) {
211
+ throw new Error(`Workspace '${id}' not found`);
212
+ }
213
+ return getWorkspace(id);
214
+ }
189
215
  /** Update the agent's permission mode (auto-accept vs plan/read-only). */
190
216
  export function updateWorkspacePermissionMode(id, permissionMode) {
191
217
  const db = getDb();
@@ -118,3 +118,53 @@ export function worktreeExists(projectPath, branchName) {
118
118
  return false;
119
119
  }
120
120
  }
121
+ function canonicalize(p) {
122
+ try {
123
+ return fs.realpathSync(p);
124
+ }
125
+ catch {
126
+ return p;
127
+ }
128
+ }
129
+ function detectSourceBranch(projectPath, worktreePath, branch) {
130
+ // 1. Branch's tracked upstream (configured locally)
131
+ try {
132
+ const upstream = git(worktreePath, ['config', '--get', `branch.${branch}.merge`]);
133
+ if (upstream)
134
+ return upstream.replace(/^refs\/heads\//, '');
135
+ }
136
+ catch {
137
+ /* no upstream configured */
138
+ }
139
+ // 2. Repo's default branch (origin/HEAD)
140
+ try {
141
+ const head = git(projectPath, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
142
+ if (head)
143
+ return head.replace(/^refs\/remotes\/origin\//, '');
144
+ }
145
+ catch {
146
+ /* no origin/HEAD */
147
+ }
148
+ // 3. Final fallback
149
+ return 'main';
150
+ }
151
+ /**
152
+ * List worktrees of a project that are NOT yet attached to a Kōbō workspace.
153
+ * The main worktree is excluded. Detached HEAD worktrees are excluded (no
154
+ * branch to anchor a workspace to). Both sides of the path comparison are
155
+ * canonicalized to defeat symlinks / trailing-slash variants.
156
+ */
157
+ export function listOrphanWorktrees(projectPath, attachedPaths) {
158
+ const canonAttached = new Set(Array.from(attachedPaths).map(canonicalize));
159
+ const canonProject = canonicalize(projectPath);
160
+ return listWorktrees(projectPath)
161
+ .filter((wt) => canonicalize(wt.path) !== canonProject)
162
+ .filter((wt) => !!wt.branch && wt.branch !== '(detached HEAD)')
163
+ .filter((wt) => !canonAttached.has(canonicalize(wt.path)))
164
+ .map((wt) => ({
165
+ path: wt.path,
166
+ branch: wt.branch,
167
+ head: wt.head,
168
+ suggestedSourceBranch: detectSourceBranch(projectPath, wt.path, wt.branch),
169
+ }));
170
+ }
@@ -59,6 +59,13 @@ export function spawnMcpProcess(command, args, env) {
59
59
  stdio: ['pipe', 'pipe', 'pipe'],
60
60
  env,
61
61
  });
62
+ // Defensive default 'error' listener: a ChildProcess that emits 'error'
63
+ // without any listener crashes the whole Node process (Unhandled 'error').
64
+ // Callers may attach their own listener for typed handling — listeners are
65
+ // additive on EventEmitter so this default never blocks them.
66
+ mcpProcess.on('error', (err) => {
67
+ console.error(`[mcp] spawn '${command}' failed: ${err.message}`);
68
+ });
62
69
  mcpProcess.stderr?.on('data', (data) => {
63
70
  if (process.env.DEBUG_MCP_STDERR) {
64
71
  console.error('[mcp stderr]', data.toString());
@@ -6,21 +6,59 @@
6
6
  * injected at workspace creation when autoLoop=true). Keeping these two
7
7
  * paths aligned was a copy-paste hazard — this file eliminates that.
8
8
  *
9
- * `AUTO_LOOP_GROOMING_STEPS` is the numbered workflow, ready to be spliced
10
- * into a larger prompt with a one-line intro.
9
+ * `buildAutoLoopGroomingSteps(e2e)` is the numbered workflow, ready to be
10
+ * spliced into a larger prompt with a one-line intro. When `e2e.framework`
11
+ * is set, an additional step 4 covers E2E regression coverage and the
12
+ * `kobo__mark_auto_loop_ready` call moves to step 5.
13
+ *
14
+ * `buildE2eIterationBlock(e2e)` returns the override block injected into
15
+ * the per-iteration prompt for tasks whose title starts with `[E2E]`.
11
16
  *
12
17
  * `AUTO_LOOP_HARD_RULES` is the trailing hard-rules block, same for both.
13
18
  */
14
- export const AUTO_LOOP_GROOMING_STEPS = `1. Call \`kobo__list_tasks\` FIRST to inspect any pre-existing tasks (they may have been seeded from Notion, a template, or the CreatePage form).
15
- 2. If tasks already exist: DO NOT delete or recreate them from scratch. Read each one, judge whether it is atomic and implementable in one session with clear completion criteria. Improve them in place:
19
+ export const PREP_AUTOLOOP_INTRO = `You are preparing this workspace for Kōbō auto-loop mode. This is a GROOMING session only — DO NOT implement anything, DO NOT write or edit code, DO NOT run tests or builds, DO NOT invoke \`superpowers:executing-plans\` or any implementation skill. Your ONLY job is to curate the Kōbō task list via MCP tools.`;
20
+ export function buildAutoLoopGroomingSteps(e2e) {
21
+ const steps = [
22
+ `1. Call \`kobo__list_tasks\` FIRST to inspect any pre-existing tasks (they may have been seeded from Notion, a template, or the CreatePage form).`,
23
+ `2. If tasks already exist: DO NOT delete or recreate them from scratch. Read each one, judge whether it is atomic and implementable in one session with clear completion criteria. Improve them in place:
16
24
  - Use \`kobo__update_task\` to rename unclear titles, add completion criteria, or flip \`is_acceptance_criterion\` when needed.
17
25
  - Use \`kobo__create_task\` to SPLIT a task that is too large into smaller atomic pieces (keep the original only if it still makes sense, otherwise update it to one of the split pieces and create the rest).
18
- - Use \`kobo__create_task\` to ADD missing acceptance criteria or missing implementation steps the plan requires.
19
- 3. If no tasks exist:
26
+ - Use \`kobo__create_task\` to ADD missing acceptance criteria or missing implementation steps the plan requires.`,
27
+ `3. If no tasks exist:
20
28
  - If a plan file exists in \`docs/superpowers/plans/\` or similar, read it and derive the task list from it.
21
29
  - If no plan exists, ask the user what the workspace goal is and propose tasks accordingly.
22
- - Create the tasks via \`kobo__create_task\`. For each task, decide \`is_acceptance_criterion\` appropriately.
23
- 4. Call \`kobo__mark_auto_loop_ready\`. This will automatically start the auto-loop, which will pick up the tasks one by one in fresh sessions.`;
30
+ - Create the tasks via \`kobo__create_task\`. For each task, decide \`is_acceptance_criterion\` appropriately.`,
31
+ ];
32
+ if (e2e.framework) {
33
+ const skillHint = e2e.skill ? `Use the \`${e2e.skill}\` skill for this task. ` : '';
34
+ const promptHint = e2e.prompt ? `Additional guidance: ${e2e.prompt}` : '';
35
+ steps.push(`4. **E2E review**: walk the task list and identify which tasks produce user-visible behavior (UI flows, form submissions, page renders, etc.). For each one that warrants regression coverage, INSERT a follow-up sub-task with title prefixed \`[E2E] \` describing the test to write. Place it in \`sort_order\` directly after the parent task. Skip tasks that don't produce user-visible behavior (refactors, infra, internal services) and briefly justify your choices in chat. The project uses \`${e2e.framework}\`. ${skillHint}${promptHint}`.trim());
36
+ steps.push(`5. Call \`kobo__mark_auto_loop_ready\`. This will automatically start the auto-loop, which will pick up the tasks one by one in fresh sessions.`);
37
+ }
38
+ else {
39
+ steps.push(`4. Call \`kobo__mark_auto_loop_ready\`. This will automatically start the auto-loop, which will pick up the tasks one by one in fresh sessions.`);
40
+ }
41
+ return steps.join('\n');
42
+ }
43
+ /** @deprecated Use buildAutoLoopGroomingSteps({ framework: '', skill: '', prompt: '' }) instead. */
44
+ export const AUTO_LOOP_GROOMING_STEPS = buildAutoLoopGroomingSteps({ framework: '', skill: '', prompt: '' });
45
+ export function buildE2eIterationBlock(e2e) {
46
+ if (!e2e.framework)
47
+ return '';
48
+ const skillLine = e2e.skill ? `Use the \`${e2e.skill}\` skill for this task.\n` : '';
49
+ const promptLine = e2e.prompt ? `Additional guidance: ${e2e.prompt}\n` : '';
50
+ return `
51
+ This is an **E2E regression test** task.
52
+
53
+ Project E2E framework: ${e2e.framework}
54
+ ${skillLine}${promptLine}
55
+ Hard rules specific to E2E tasks (these **override** the corresponding rules in the standard 8 steps below — read them before steps 3-4):
56
+ 1. Write the test source file in the project's existing E2E directory (look at \`cypress/\`, \`e2e/\`, \`tests/e2e/\`, or follow the skill / guidance above). Reuse existing fixtures and patterns.
57
+ 2. Try to run the tests locally. If they pass, great.
58
+ 3. **If the environment is broken** (Docker down, browser missing, port busy, dependencies not installed, etc.) — do NOT spend iterations debugging infra. **Override of step 4 of the standard prompt below**: you do NOT need to fix failing tests in this case. Commit the test source file with a message like \`test(e2e): add regression for <feature>\`, then call \`kobo__mark_task_done\` with a note in the chat: \`E2E test written but not executed locally — <reason>. Replay once env is restored.\`
59
+ 4. The code-review gate (step 6 of the standard prompt) still applies — the reviewer checks that the test is meaningful, not that it ran.
60
+ `;
61
+ }
24
62
  export const AUTO_LOOP_HARD_RULES = `Hard rules:
25
63
  - Do NOT touch any source file. No Edit, no Write, no Bash that changes the repo.
26
64
  - Do NOT run \`kill\`, \`pkill\`, \`killall\`, \`pgrep -k\`, or any process-killing command — you may tear down the Kōbō server itself or sibling dev servers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.6.11",
3
+ "version": "1.6.13",
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",