@loicngr/kobo 1.6.12 → 1.6.14

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 (87) hide show
  1. package/README.md +11 -6
  2. package/dist/mcp-server/kobo-tasks-handlers.js +2 -2
  3. package/dist/server/db/migrations.js +24 -0
  4. package/dist/server/db/schema.js +10 -0
  5. package/dist/server/index.js +27 -4
  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 +209 -81
  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 +29 -4
  15. package/dist/server/services/dev-server-service.js +2 -5
  16. package/dist/server/services/settings-service.js +55 -2
  17. package/dist/server/services/usage/db.js +29 -0
  18. package/dist/server/services/usage/index.js +2 -0
  19. package/dist/server/services/usage/poller.js +52 -0
  20. package/dist/server/services/usage/providers/claude-code.js +93 -0
  21. package/dist/server/services/usage/types.js +1 -0
  22. package/dist/server/services/wakeup-service.js +2 -2
  23. package/dist/server/services/websocket-service.js +14 -0
  24. package/dist/server/services/workspace-service.js +28 -3
  25. package/dist/server/services/worktree-service.js +50 -0
  26. package/dist/server/utils/mcp-client.js +7 -0
  27. package/dist/shared/auto-loop-prompts.js +58 -8
  28. package/package.json +1 -1
  29. package/src/client/dist/spa/assets/ActivityFeed-Be0QQryJ.css +1 -0
  30. package/src/client/dist/spa/assets/{ActivityFeed-6Xg7qNfy.js → ActivityFeed-BtIOkIy6.js} +3 -3
  31. package/src/client/dist/spa/assets/CreatePage-D6Q3nxkX.js +2 -0
  32. package/src/client/dist/spa/assets/CreatePage-DJbZH8wp.css +1 -0
  33. package/src/client/dist/spa/assets/DiffViewer-1s165rFm.css +1 -0
  34. package/src/client/dist/spa/assets/{DiffViewer-T111s7BH.js → DiffViewer-D5u9p7il.js} +2 -2
  35. package/src/client/dist/spa/assets/{HealthPage-1VakQ0x_.js → HealthPage-Cr7aAUy6.js} +1 -1
  36. package/src/client/dist/spa/assets/{MainLayout-w7DoW3yz.js → MainLayout-C3TUaYvQ.js} +17 -17
  37. package/src/client/dist/spa/assets/MainLayout-CBnSwSfy.css +1 -0
  38. package/src/client/dist/spa/assets/{SearchPage-CcldJX8i.js → SearchPage-CavRaij6.js} +1 -1
  39. package/src/client/dist/spa/assets/SearchPage-cVwt0DaQ.css +1 -0
  40. package/src/client/dist/spa/assets/SettingsPage-B8DhSZw7.css +1 -0
  41. package/src/client/dist/spa/assets/SettingsPage-C13T1l_t.js +1 -0
  42. package/src/client/dist/spa/assets/WorkspacePage-BEqEuPrb.js +4 -0
  43. package/src/client/dist/spa/assets/WorkspacePage-k2pgeRoy.css +1 -0
  44. package/src/client/dist/spa/assets/{build-path-tree-DbuI5yRz.js → build-path-tree-BeAS10oa.js} +1 -1
  45. package/src/client/dist/spa/assets/{cssMode-DhpmJAZc.js → cssMode-wNaxOrgG.js} +1 -1
  46. package/src/client/dist/spa/assets/{documents-fVD9RJth.js → documents-Cw05r3zs.js} +1 -1
  47. package/src/client/dist/spa/assets/{editor.api-DCvwHsju.js → editor.api-CcDntllS.js} +1 -1
  48. package/src/client/dist/spa/assets/{editor.main-CRtPC0iL.js → editor.main-Chu4hc0J.js} +3 -3
  49. package/src/client/dist/spa/assets/{expand-template-BIra7NIw.js → expand-template-CcQus77v.js} +1 -1
  50. package/src/client/dist/spa/assets/expand-template-D2yUa54D.css +1 -0
  51. package/src/client/dist/spa/assets/{freemarker2-C9UOErQw.js → freemarker2-CO_b202E.js} +1 -1
  52. package/src/client/dist/spa/assets/{handlebars-DmZ2-ZcJ.js → handlebars-CJnTWNLs.js} +1 -1
  53. package/src/client/dist/spa/assets/{html-ButyxlXG.js → html-DeArYseI.js} +1 -1
  54. package/src/client/dist/spa/assets/{htmlMode-C-defy1b.js → htmlMode-BnNgEgdx.js} +1 -1
  55. package/src/client/dist/spa/assets/i18n-CuT4b7ns.js +1 -0
  56. package/src/client/dist/spa/assets/index-CZA4BFN5.js +2 -0
  57. package/src/client/dist/spa/assets/{javascript-B6zVweIF.js → javascript-C0pxfNu4.js} +1 -1
  58. package/src/client/dist/spa/assets/{jsonMode-CttMw-EY.js → jsonMode-ety87201.js} +1 -1
  59. package/src/client/dist/spa/assets/kobo-commands-Cpl4IFon.js +11 -0
  60. package/src/client/dist/spa/assets/{liquid-tGpdE1YW.js → liquid-kanevKvC.js} +1 -1
  61. package/src/client/dist/spa/assets/{mdx-Cy5mpQoy.js → mdx-DkmtbRD7.js} +1 -1
  62. package/src/client/dist/spa/assets/{models-DdAQDnqk.js → models-CPFeBEQS.js} +1 -1
  63. package/src/client/dist/spa/assets/{monaco.contribution-DtdkkTgR.js → monaco.contribution-DsZsua59.js} +2 -2
  64. package/src/client/dist/spa/assets/{python-hLOxMbm9.js → python-DrxH1xl7.js} +1 -1
  65. package/src/client/dist/spa/assets/{razor-tqHFRROa.js → razor-CU4khv8N.js} +1 -1
  66. package/src/client/dist/spa/assets/stats-C3n1k51k.js +1 -0
  67. package/src/client/dist/spa/assets/{tsMode-MJKgZYsJ.js → tsMode-CQ5yxoz_.js} +1 -1
  68. package/src/client/dist/spa/assets/{typescript-CWTqB5lb.js → typescript-CSwKmP7l.js} +1 -1
  69. package/src/client/dist/spa/assets/{xml-ByDBLBVa.js → xml-9bnWANPJ.js} +1 -1
  70. package/src/client/dist/spa/assets/{yaml-BiTCWZ38.js → yaml-sUtDJGxo.js} +1 -1
  71. package/src/client/dist/spa/index.html +1 -1
  72. package/src/mcp-server/kobo-tasks-handlers.ts +3 -2
  73. package/src/client/dist/spa/assets/ActivityFeed-BHDJ5lUn.css +0 -1
  74. package/src/client/dist/spa/assets/CreatePage-BQu7mQjm.css +0 -1
  75. package/src/client/dist/spa/assets/CreatePage-CrRGDs5V.js +0 -2
  76. package/src/client/dist/spa/assets/DiffViewer-BC81-2me.css +0 -1
  77. package/src/client/dist/spa/assets/MainLayout-Ci-CETJi.css +0 -1
  78. package/src/client/dist/spa/assets/SearchPage-DWglAeQv.css +0 -1
  79. package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +0 -1
  80. package/src/client/dist/spa/assets/SettingsPage-CMyeaz63.js +0 -1
  81. package/src/client/dist/spa/assets/WorkspacePage-Bw9xhTDR.js +0 -4
  82. package/src/client/dist/spa/assets/WorkspacePage-_1mty_a4.css +0 -1
  83. package/src/client/dist/spa/assets/expand-template-hbnn7St6.css +0 -1
  84. package/src/client/dist/spa/assets/i18n-C8aJvuyS.js +0 -1
  85. package/src/client/dist/spa/assets/index-DAbX631s.js +0 -2
  86. package/src/client/dist/spa/assets/kobo-commands-CD7ERFxp.js +0 -10
  87. package/src/client/dist/spa/assets/rate-limit-labels-BaD9dQtl.js +0 -1
@@ -53,6 +53,12 @@ Please:
53
53
  3. Post a comment on the PR summarizing what was done and any follow-up items
54
54
  4. Do NOT add a "Generated with Claude Code" footer or any AI attribution to the PR description
55
55
  `;
56
+ export const DEFAULT_FINALIZATION_PROMPT = `Run final quality checks before closing the workspace:
57
+
58
+ 1. Verify all other tasks are marked \`done\`. If any remain \`pending\`, stop and report.
59
+ 2. Run the project's linters, type-checkers, and tests (see CLAUDE.md or package.json scripts).
60
+ 3. If any check fails, create a new regular task at the end of the list with a title like \`Fix lint failure in X\` (NO \`[FINAL]\` or \`[E2E]\` prefix — it must use the default iteration prompt) and mark this \`[FINAL]\` task as \`done\`. The auto-loop will pick up the fix on the next iteration. The finalization mechanism is single-shot per grooming pass; if you want quality checks to re-run after the fix, mark the fix task \`done\` and re-trigger grooming manually.
61
+ 4. If everything passes, mark this task as \`done\`.`;
56
62
  /** Default workspace tags seeded on fresh install and on settings upgrade. */
57
63
  export const DEFAULT_WORKSPACE_TAGS = [
58
64
  'bug',
@@ -170,6 +176,21 @@ const settingsMigrations = [
170
176
  }
171
177
  },
172
178
  },
179
+ {
180
+ version: 10,
181
+ name: 'add-project-finalization',
182
+ migrate({ projects }) {
183
+ for (const p of projects) {
184
+ if (!p.finalization || typeof p.finalization !== 'object') {
185
+ p.finalization = { prompt: DEFAULT_FINALIZATION_PROMPT };
186
+ }
187
+ else if (typeof p.finalization.prompt !== 'string') {
188
+ ;
189
+ p.finalization.prompt = DEFAULT_FINALIZATION_PROMPT;
190
+ }
191
+ }
192
+ },
193
+ },
173
194
  ];
174
195
  /** Current settings schema version — always equals the highest migration version. */
175
196
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -228,6 +249,14 @@ function defaultProjectSettings(projectPath) {
228
249
  startCommand: '',
229
250
  stopCommand: '',
230
251
  },
252
+ e2e: {
253
+ framework: '',
254
+ skill: '',
255
+ prompt: '',
256
+ },
257
+ finalization: {
258
+ prompt: DEFAULT_FINALIZATION_PROMPT,
259
+ },
231
260
  };
232
261
  }
233
262
  function pickKnownKeys(data, allowedKeys) {
@@ -434,7 +463,7 @@ export function updateGlobalSettings(data) {
434
463
  writeSettings(settings, { backup: true });
435
464
  return settings.global;
436
465
  }
437
- /** Create or update project-specific settings. Merges devServer fields on update. */
466
+ /** Create or update project-specific settings. Merges devServer, e2e, and finalization fields on update. */
438
467
  export function upsertProject(projectPath, data) {
439
468
  const allowedProjectKeys = [
440
469
  'displayName',
@@ -445,23 +474,41 @@ export function upsertProject(projectPath, data) {
445
474
  'gitConventions',
446
475
  'setupScript',
447
476
  'devServer',
477
+ 'e2e',
478
+ 'finalization',
448
479
  ];
449
480
  const allowedDevServerKeys = ['startCommand', 'stopCommand'];
481
+ const allowedE2eKeys = ['framework', 'skill', 'prompt'];
482
+ const allowedFinalizationKeys = ['prompt'];
450
483
  const filtered = pickKnownKeys(data, allowedProjectKeys);
451
484
  if (filtered.devServer) {
452
485
  filtered.devServer = pickKnownKeys(filtered.devServer, allowedDevServerKeys);
453
486
  }
487
+ if (filtered.e2e) {
488
+ filtered.e2e = pickKnownKeys(filtered.e2e, allowedE2eKeys);
489
+ }
490
+ if (filtered.finalization) {
491
+ filtered.finalization = pickKnownKeys(filtered.finalization, allowedFinalizationKeys);
492
+ }
454
493
  const settings = readSettings();
455
494
  const idx = settings.projects.findIndex((p) => p.path === projectPath);
456
495
  if (idx >= 0) {
457
- // Update existing project — merge devServer separately to allow partial updates
496
+ // Update existing project — merge devServer, e2e, and finalization separately to allow partial updates
458
497
  const existing = settings.projects[idx];
459
498
  const updatedDevServer = filtered.devServer ? { ...existing.devServer, ...filtered.devServer } : existing.devServer;
499
+ const existingE2e = existing.e2e ?? defaultProjectSettings(projectPath).e2e;
500
+ const updatedE2e = filtered.e2e ? { ...existingE2e, ...filtered.e2e } : existingE2e;
501
+ const existingFinalization = existing.finalization ?? defaultProjectSettings(projectPath).finalization;
502
+ const updatedFinalization = filtered.finalization
503
+ ? { ...existingFinalization, ...filtered.finalization }
504
+ : existingFinalization;
460
505
  settings.projects[idx] = {
461
506
  ...existing,
462
507
  ...filtered,
463
508
  path: projectPath,
464
509
  devServer: updatedDevServer,
510
+ e2e: updatedE2e,
511
+ finalization: updatedFinalization,
465
512
  };
466
513
  }
467
514
  else {
@@ -474,6 +521,12 @@ export function upsertProject(projectPath, data) {
474
521
  if (filtered.devServer) {
475
522
  newProject.devServer = { ...defaultProjectSettings(projectPath).devServer, ...filtered.devServer };
476
523
  }
524
+ if (filtered.e2e) {
525
+ newProject.e2e = { ...defaultProjectSettings(projectPath).e2e, ...filtered.e2e };
526
+ }
527
+ if (filtered.finalization) {
528
+ newProject.finalization = { ...defaultProjectSettings(projectPath).finalization, ...filtered.finalization };
529
+ }
477
530
  settings.projects.push(newProject);
478
531
  }
479
532
  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 {
@@ -43,6 +43,8 @@ function mapWorkspace(row) {
43
43
  autoLoopReady: row.auto_loop_ready === 1,
44
44
  noProgressStreak: row.no_progress_streak ?? 0,
45
45
  permissionProfile: (row.permission_profile ?? 'bypass'),
46
+ worktreePath: row.worktree_path ?? '',
47
+ worktreeOwned: row.worktree_owned === 1,
46
48
  createdAt: row.created_at,
47
49
  updatedAt: row.updated_at,
48
50
  };
@@ -78,10 +80,15 @@ export function createWorkspace(data) {
78
80
  const db = getDb();
79
81
  const now = new Date().toISOString();
80
82
  const id = nanoid();
83
+ const computedWorktreePath = data.worktreePath ?? `${data.projectPath}/.worktrees/${data.workingBranch}`;
84
+ const owned = data.worktreeOwned ?? true;
81
85
  db.prepare(`
82
- INSERT INTO workspaces (id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, sentry_url, model, reasoning_effort, permission_mode, engine, created_at, updated_at)
83
- VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?)
84
- `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? 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);
85
92
  return getWorkspace(id);
86
93
  }
87
94
  /** Fetch a single workspace by ID, or null if not found. */
@@ -114,6 +121,12 @@ export function updateWorkspaceStatus(id, status) {
114
121
  if (!workspace) {
115
122
  throw new Error(`Workspace '${id}' not found`);
116
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
+ }
117
130
  const allowedTransitions = VALID_TRANSITIONS[workspace.status];
118
131
  if (!allowedTransitions.includes(status)) {
119
132
  throw new Error(`Invalid status transition from '${workspace.status}' to '${status}'. Allowed: ${allowedTransitions.join(', ')}`);
@@ -187,6 +200,18 @@ export function updateWorkingBranch(id, workingBranch) {
187
200
  }
188
201
  return getWorkspace(id);
189
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
+ }
190
215
  /** Update the agent's permission mode (auto-accept vs plan/read-only). */
191
216
  export function updateWorkspacePermissionMode(id, permissionMode) {
192
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,71 @@
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, finalization) {
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
+ let nextNum = 4;
33
+ if (e2e.framework) {
34
+ const skillHint = e2e.skill ? `Use the \`${e2e.skill}\` skill for this task. ` : '';
35
+ const promptHint = e2e.prompt ? `Additional guidance: ${e2e.prompt}` : '';
36
+ steps.push(`${nextNum}. **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());
37
+ nextNum++;
38
+ }
39
+ if (finalization.prompt) {
40
+ steps.push(`${nextNum}. **Finalization task**: create ONE task with title prefixed \`[FINAL] <descriptive title>\`. Place it at the END of the task list (sort_order = max + 1, AFTER any [E2E] tasks). The agent will execute this task using the project's finalization prompt — do NOT inline the prompt content into the task title or description.`);
41
+ nextNum++;
42
+ }
43
+ steps.push(`${nextNum}. 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.`);
44
+ return steps.join('\n');
45
+ }
46
+ /** @deprecated Use buildAutoLoopGroomingSteps({ framework: '', skill: '', prompt: '' }, { prompt: '' }) instead. */
47
+ export const AUTO_LOOP_GROOMING_STEPS = buildAutoLoopGroomingSteps({ framework: '', skill: '', prompt: '' }, { prompt: '' });
48
+ export function buildFinalizationIterationBlock(finalization) {
49
+ if (!finalization.prompt)
50
+ return '';
51
+ return `
52
+ This is the **finalization task**. Follow this prompt instead of the standard 8 steps below:
53
+
54
+ ${finalization.prompt}
55
+ `;
56
+ }
57
+ export function buildE2eIterationBlock(e2e) {
58
+ if (!e2e.framework)
59
+ return '';
60
+ const skillLine = e2e.skill ? `Use the \`${e2e.skill}\` skill for this task.\n` : '';
61
+ const promptLine = e2e.prompt ? `Additional guidance: ${e2e.prompt}\n` : '';
62
+ return `
63
+ This is an **E2E regression test** task.
64
+
65
+ Project E2E framework: ${e2e.framework}
66
+ ${skillLine}${promptLine}
67
+ Hard rules specific to E2E tasks (these **override** the corresponding rules in the standard 8 steps below — read them before steps 3-4):
68
+ 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.
69
+ 2. Try to run the tests locally. If they pass, great.
70
+ 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.\`
71
+ 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.
72
+ `;
73
+ }
24
74
  export const AUTO_LOOP_HARD_RULES = `Hard rules:
25
75
  - Do NOT touch any source file. No Edit, no Write, no Bash that changes the repo.
26
76
  - 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.12",
3
+ "version": "1.6.14",
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
+ .message-choices[data-v-29c0dc7a]{flex-wrap:wrap;align-items:stretch;display:flex}.message-choice-btn[data-v-29c0dc7a]{background:#818cf814;border:1px solid #818cf859;border-radius:6px;max-width:360px;font-size:12px}.message-choice-btn[data-v-29c0dc7a] .q-btn__content{flex-wrap:nowrap;justify-content:flex-start}.message-choice-btn[data-v-29c0dc7a]:hover{background:#818cf82e}.choice-label[data-v-29c0dc7a]{text-overflow:ellipsis;white-space:nowrap;max-width:320px;overflow:hidden}.markdown-message[data-v-8382750f]{color:#e0e0e0;word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-8382750f] *{max-width:100%}.markdown-message[data-v-8382750f] p{margin:0 0 .5em}.markdown-message[data-v-8382750f] p:last-child{margin-bottom:0}.markdown-message[data-v-8382750f] pre{background:#00000059;border-radius:4px;margin:.5em 0;padding:.5em .75em;overflow-x:auto}.markdown-message[data-v-8382750f] code{word-break:break-all;background:#0000004d;border-radius:3px;padding:.1em .3em;font-size:.9em}.markdown-message[data-v-8382750f] pre code{background:0 0;padding:0}.markdown-message[data-v-8382750f] ul,.markdown-message[data-v-8382750f] ol{margin:.25em 0 .5em;padding-left:1.5em}.markdown-message[data-v-8382750f] li{margin:.15em 0}.markdown-message[data-v-8382750f] a{color:#7986cb;text-decoration:underline}.markdown-message[data-v-8382750f] .document-link{color:#9fa8da;cursor:pointer;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.markdown-message[data-v-8382750f] .document-link:hover{color:#c5cae9;-webkit-text-decoration:underline;text-decoration:underline}.markdown-message[data-v-8382750f] h1,.markdown-message[data-v-8382750f] h2,.markdown-message[data-v-8382750f] h3,.markdown-message[data-v-8382750f] h4,.markdown-message[data-v-8382750f] h5,.markdown-message[data-v-8382750f] h6{margin:.5em 0 .3em;font-weight:600;line-height:1.3}.markdown-message[data-v-8382750f] h1{font-size:1.25em}.markdown-message[data-v-8382750f] h2{font-size:1.15em}.markdown-message[data-v-8382750f] h3{font-size:1.08em}.markdown-message[data-v-8382750f] h4,.markdown-message[data-v-8382750f] h5,.markdown-message[data-v-8382750f] h6{font-size:1em}.markdown-message[data-v-8382750f] blockquote{color:#ffffffb3;border-left:3px solid #fff3;margin:.5em 0;padding-left:.75em}.markdown-message[data-v-8382750f] table{border-collapse:collapse;margin:.5em 0}.markdown-message[data-v-8382750f] th,.markdown-message[data-v-8382750f] td{border:1px solid #ffffff26;padding:.25em .5em}.markdown-thinking[data-v-4e64694c] p{margin:0 0 .4em}.markdown-thinking[data-v-4e64694c] p:last-child{margin-bottom:0}.markdown-thinking[data-v-4e64694c] code{background:#ffffff14;border-radius:3px;padding:.1em .3em}.tool-row[data-v-b1fcd20d]{border-radius:4px;margin:0;font-size:12px}.tool-header[data-v-b1fcd20d]{color:#bbb;cursor:default;align-items:center;gap:10px;min-width:0;padding:5px 10px;display:flex}.tool-row:not(.tool-row-generic) .tool-header[data-v-b1fcd20d],.tool-row--toggleable .tool-header[data-v-b1fcd20d]{cursor:pointer}.tool-row:has(.tool-diff) .tool-header[data-v-b1fcd20d]{cursor:pointer}.tool-row:not(.tool-row-generic) .tool-header[data-v-b1fcd20d]:hover,.tool-row--toggleable .tool-header[data-v-b1fcd20d]:hover{background:#ffffff08}.tool-icon[data-v-b1fcd20d]{color:#9fbce0;flex-shrink:0}.tool-name[data-v-b1fcd20d]{color:#d0d0d0;flex-shrink:0;font-weight:600}.tool-arg[data-v-b1fcd20d],.tool-path[data-v-b1fcd20d]{color:#999;text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:100%;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11.5px;overflow:hidden}.tool-path[data-v-b1fcd20d],.tool-arg[data-v-b1fcd20d]{flex:1}.tool-stat-add[data-v-b1fcd20d]{color:#66bb6a;flex-shrink:0;font-size:11px;font-weight:600}.tool-stat-del[data-v-b1fcd20d]{color:#ef5350;flex-shrink:0;font-size:11px;font-weight:600}.tool-diff[data-v-b1fcd20d]{background:#0003;border-radius:4px;max-height:400px;margin-top:4px;padding:8px 0;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;overflow:auto}.diff-line[data-v-b1fcd20d]{white-space:pre;color:#bbb;padding:0 12px}.diff-sign[data-v-b1fcd20d]{color:#555;-webkit-user-select:none;user-select:none;width:14px;display:inline-block}.diff-add[data-v-b1fcd20d]{color:#c8e6c9;background:#66bb6a1a}.diff-add .diff-sign[data-v-b1fcd20d]{color:#66bb6a}.diff-del[data-v-b1fcd20d]{color:#ffcdd2;background:#ef53501a}.diff-del .diff-sign[data-v-b1fcd20d]{color:#ef5350}.tool-output[data-v-b1fcd20d]{color:#aaa;white-space:pre-wrap;background:#00000026;border-radius:4px;max-height:8em;margin-top:4px;padding:6px 10px;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;overflow:auto}.markdown-message[data-v-c2e7c407]{color:#e0e0e0;word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-c2e7c407] *{max-width:100%}.markdown-message[data-v-c2e7c407] img{-o-object-fit:contain;object-fit:contain;cursor:zoom-in;background:#0003;border-radius:4px;max-width:180px;max-height:100px;margin:.3em 0;display:block}.markdown-message[data-v-c2e7c407] code{word-break:break-all}.markdown-message[data-v-c2e7c407] p{margin:0 0 .4em}.markdown-message[data-v-c2e7c407] p:last-child{margin-bottom:0}.markdown-message[data-v-c2e7c407] code{background:#00000040;border-radius:3px;padding:.1em .3em}.markdown-message[data-v-c2e7c407] h1,.markdown-message[data-v-c2e7c407] h2,.markdown-message[data-v-c2e7c407] h3,.markdown-message[data-v-c2e7c407] h4,.markdown-message[data-v-c2e7c407] h5,.markdown-message[data-v-c2e7c407] h6{margin:.4em 0 .25em;font-weight:600;line-height:1.3}.markdown-message[data-v-c2e7c407] h1{font-size:1.25em}.markdown-message[data-v-c2e7c407] h2{font-size:1.15em}.markdown-message[data-v-c2e7c407] h3{font-size:1.08em}.markdown-message[data-v-c2e7c407] h4,.markdown-message[data-v-c2e7c407] h5,.markdown-message[data-v-c2e7c407] h6{font-size:1em}.markdown-user-prompt[data-v-c2e7c407]{color:#aaa;font-size:12px;font-style:italic}.markdown-user-prompt[data-v-c2e7c407] p{margin:0 0 .4em}.markdown-user-prompt[data-v-c2e7c407] code{background:#ffffff14;border-radius:3px;padding:.1em .3em;font-style:normal}.image-lightbox-img{-o-object-fit:contain;object-fit:contain;cursor:zoom-out;background:#0000004d;border-radius:4px;max-width:92vw;max-height:92vh;display:block}.turn-card[data-v-813aff2b]{border:1px solid #ffffff14;border-left:3px solid var(--turn-accent);background:#ffffff05;border-radius:6px;min-width:0;max-width:100%;margin:14px 0;overflow:hidden}.turn-header[data-v-813aff2b]{color:#888;background:#ffffff08;border-bottom:1px solid #ffffff0d;align-items:center;gap:8px;padding:8px 14px;font-size:11px;display:flex}.turn-badge[data-v-813aff2b]{letter-spacing:.3px;border-radius:3px;padding:2px 8px;font-size:11px;font-weight:700}.turn-badge-user[data-v-813aff2b]{color:#ce93d8;background:#ce93d826}.turn-badge-agent[data-v-813aff2b]{color:#7986cb;background:#7986cb26}.turn-badge-system[data-v-813aff2b]{color:#bdbdbd;background:#75757533;font-style:italic}.turn-badge-session[data-v-813aff2b]{color:#9e9e9e;background:#61616133}.turn-time[data-v-813aff2b]{color:#666;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px}.turn-time-arrow[data-v-813aff2b]{opacity:.7;margin:0 -2px}.turn-time-updated[data-v-813aff2b]{color:#8891a3}.turn-actions[data-v-813aff2b]{color:#777;font-size:11px}.turn-body[data-v-813aff2b]{flex-direction:column;gap:12px;min-width:0;padding:14px 18px;display:flex}.turn-body[data-v-813aff2b]>*{min-width:0;max-width:100%}.turn-body[data-v-813aff2b] .tool-row+.tool-row{margin-top:-8px}.turn-scroll-top[data-v-813aff2b]{justify-content:flex-start;padding:0 8px 6px;display:flex}.turn-scroll-top-btn[data-v-813aff2b]{opacity:.5;transition:opacity .15s}.turn-scroll-top-btn[data-v-813aff2b]:hover{opacity:1}.activity-feed-wrap[data-v-ea6b67c0]{width:100%;height:100%;position:relative}.activity-feed-scroll[data-v-ea6b67c0]{width:100%;height:100%}.activity-feed-nav-cluster[data-v-ea6b67c0]{z-index:2;align-items:center;gap:8px;display:flex;position:absolute;bottom:14px;right:14px}.activity-feed-nav-btn[data-v-ea6b67c0]{opacity:.8;transition:opacity .12s}.activity-feed-nav-btn[data-v-ea6b67c0]:hover{opacity:1}.content-origin-marker[data-v-ea6b67c0]{pointer-events:none;width:0;height:0;margin:0;padding:0}.activity-feed-scroll[data-v-ea6b67c0] .q-scrollarea__content{max-width:100%;overflow-x:hidden}.activity-feed-switching[data-v-ea6b67c0]{justify-content:center;align-items:center;width:100%;height:100%;display:flex}