@loicngr/kobo 1.7.13 → 1.7.15

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 (127) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +81 -399
  3. package/dist/server/db/migrations.js +33 -0
  4. package/dist/server/index.js +21 -2
  5. package/dist/server/routes/changelog.js +47 -0
  6. package/dist/server/routes/export.js +71 -0
  7. package/dist/server/routes/fs.js +49 -0
  8. package/dist/server/routes/git.js +16 -1
  9. package/dist/server/routes/health.js +2 -1
  10. package/dist/server/routes/workspaces.js +107 -64
  11. package/dist/server/services/agent/orchestrator.js +10 -0
  12. package/dist/server/services/archive-script-service.js +65 -0
  13. package/dist/server/services/auto-loop-service.js +7 -0
  14. package/dist/server/services/cleanup-script-service.js +118 -0
  15. package/dist/server/services/settings-service.js +184 -2
  16. package/dist/server/services/setup-script-service.js +10 -81
  17. package/dist/server/utils/git-ops.js +30 -0
  18. package/dist/server/utils/paths.js +4 -0
  19. package/dist/server/utils/script-runner.js +96 -0
  20. package/package.json +2 -1
  21. package/src/client/dist/spa/assets/ActivityFeed-DU6lDEP0.js +8 -0
  22. package/src/client/dist/spa/assets/{ActivityFeed-WjiQ9716.css → ActivityFeed-yUMQhnW4.css} +1 -1
  23. package/src/client/dist/spa/assets/{ClosePopup-C5EF3QrA.js → ClosePopup-CxvZA3ft.js} +1 -1
  24. package/src/client/dist/spa/assets/CreatePage-CdZr7f3j.js +2 -0
  25. package/src/client/dist/spa/assets/CreatePage-DgHjL4cZ.css +1 -0
  26. package/src/client/dist/spa/assets/{DiffViewer-hWxcG22N.js → DiffViewer-m801GPfI.js} +3 -3
  27. package/src/client/dist/spa/assets/HealthPage-z1uIOpYk.js +1 -0
  28. package/src/client/dist/spa/assets/{MainLayout-Bp0oVWa-.css → MainLayout-BJmBXwYn.css} +1 -1
  29. package/src/client/dist/spa/assets/MainLayout-oJdQ-QKM.js +37 -0
  30. package/src/client/dist/spa/assets/{QBadge-u0mEz_W1.js → QBadge-NEwszYs7.js} +1 -1
  31. package/src/client/dist/spa/assets/QBanner-Jsq4uJZs.js +1 -0
  32. package/src/client/dist/spa/assets/QBtn-CoU-UC_j.js +1 -0
  33. package/src/client/dist/spa/assets/{QCheckbox-skYuqkHX.js → QCheckbox-Cq2STfHp.js} +1 -1
  34. package/src/client/dist/spa/assets/{QChip-DSMeriN6.js → QChip-DnJyQVs2.js} +2 -2
  35. package/src/client/dist/spa/assets/QExpansionItem-BTd5m2yV.js +1 -0
  36. package/src/client/dist/spa/assets/QIcon-BmEX2rXO.js +1 -0
  37. package/src/client/dist/spa/assets/QInput-D0t39uK_.js +1 -0
  38. package/src/client/dist/spa/assets/{QItemLabel-B0tYxHQg.js → QItemLabel-Btqw0P7M.js} +1 -1
  39. package/src/client/dist/spa/assets/QItemSection-DRg-QuAD.js +1 -0
  40. package/src/client/dist/spa/assets/QList-B3TuWSqL.js +1 -0
  41. package/src/client/dist/spa/assets/QMenu-C2Wwwf2E.js +1 -0
  42. package/src/client/dist/spa/assets/QPage-DO_bQyV_.js +1 -0
  43. package/src/client/dist/spa/assets/QRadio-M9mC5jZy.js +1 -0
  44. package/src/client/dist/spa/assets/QScrollArea-A1wI0IXU.js +1 -0
  45. package/src/client/dist/spa/assets/QSpace-DONPiIes.js +1 -0
  46. package/src/client/dist/spa/assets/{QSpinnerDots-CluOpUgq.js → QSpinnerDots-DspFKwCZ.js} +1 -1
  47. package/src/client/dist/spa/assets/{QToggle-aBvIHg6j.js → QToggle-DBzTAIbK.js} +1 -1
  48. package/src/client/dist/spa/assets/QTooltip-Bfdmzm_m.js +1 -0
  49. package/src/client/dist/spa/assets/SearchPage-ChmKHNKn.js +1 -0
  50. package/src/client/dist/spa/assets/SettingsPage-B59LoCos.js +9 -0
  51. package/src/client/dist/spa/assets/{SettingsPage-B_qPRSDH.css → SettingsPage-BJLyYrBN.css} +1 -1
  52. package/src/client/dist/spa/assets/{TouchPan-DPBZDRzA.js → TouchPan-BIE5rs7U.js} +1 -1
  53. package/src/client/dist/spa/assets/WorkspacePage-Bj1PJSWT.js +4 -0
  54. package/src/client/dist/spa/assets/WorkspacePage-tFBswKV9.css +1 -0
  55. package/src/client/dist/spa/assets/build-path-tree-BGUV3nY1.js +1 -0
  56. package/src/client/dist/spa/assets/{cssMode-DTibHxcy.js → cssMode-BU4X8R6a.js} +1 -1
  57. package/src/client/dist/spa/assets/documents-B3nitIYF.js +1 -0
  58. package/src/client/dist/spa/assets/{editor.api-CdLzoLLv.js → editor.api-B4xBDzmJ.js} +1 -1
  59. package/src/client/dist/spa/assets/{editor.main-DSLdinVQ.js → editor.main-CSZRkloL.js} +3 -3
  60. package/src/client/dist/spa/assets/engineFeatures-CLOVr5b4.js +1 -0
  61. package/src/client/dist/spa/assets/expand-template-BxUkuL5g.js +1 -0
  62. package/src/client/dist/spa/assets/{formatters-CgfY9uSI.js → formatters-9dcj2tyJ.js} +1 -1
  63. package/src/client/dist/spa/assets/{freemarker2-wf8G5c3b.js → freemarker2-DRz20wAV.js} +1 -1
  64. package/src/client/dist/spa/assets/{handlebars-BGPZbdOB.js → handlebars-C0dsvPnC.js} +1 -1
  65. package/src/client/dist/spa/assets/{html-DPEVCP74.js → html-Cqvj1pWs.js} +1 -1
  66. package/src/client/dist/spa/assets/{htmlMode-DMWUNRWz.js → htmlMode-BTHNvkm6.js} +1 -1
  67. package/src/client/dist/spa/assets/i18n-D1I-Us2H.js +1 -0
  68. package/src/client/dist/spa/assets/{index-5ydpLSpt.css → index-ClabzOPc.css} +1 -1
  69. package/src/client/dist/spa/assets/index-KABmOIkF.js +2 -0
  70. package/src/client/dist/spa/assets/{javascript-hOcrXbaP.js → javascript-C8n3U02v.js} +1 -1
  71. package/src/client/dist/spa/assets/{jsonMode-EeNIyfJU.js → jsonMode-C3AFxQ6K.js} +1 -1
  72. package/src/client/dist/spa/assets/{kobo-commands-Bh5k0Smw.js → kobo-commands-BuxgteGZ.js} +1 -1
  73. package/src/client/dist/spa/assets/{liquid-Cvw1gFPR.js → liquid-C4wtUDrJ.js} +1 -1
  74. package/src/client/dist/spa/assets/{mdx-CGtsv5px.js → mdx-CaT1p1F2.js} +1 -1
  75. package/src/client/dist/spa/assets/{monaco.contribution-RY1eW2u5.js → monaco.contribution-CJg5GKVf.js} +2 -2
  76. package/src/client/dist/spa/assets/notifications-BC6en6Lt.js +1 -0
  77. package/src/client/dist/spa/assets/permissionModes-BQHBTBwa.js +1 -0
  78. package/src/client/dist/spa/assets/{python-t0GNay3W.js → python-Cj54W2Tg.js} +1 -1
  79. package/src/client/dist/spa/assets/{razor-v9eEhqO6.js → razor-D3gJxoX_.js} +1 -1
  80. package/src/client/dist/spa/assets/render-chat-markdown-DxEHr3lW.js +60 -0
  81. package/src/client/dist/spa/assets/runtime-core.esm-bundler-D_RRiKBh.js +1 -0
  82. package/src/client/dist/spa/assets/{tsMode-B376_f_2.js → tsMode-B6S4PLWH.js} +1 -1
  83. package/src/client/dist/spa/assets/{typescript-WTvQ8gc0.js → typescript-Ca8AEX3t.js} +1 -1
  84. package/src/client/dist/spa/assets/use-checkbox-DnSuDqo2.js +1 -0
  85. package/src/client/dist/spa/assets/use-id-BCnfiBjU.js +1 -0
  86. package/src/client/dist/spa/assets/use-onboarding-B3l7mx48.css +1 -0
  87. package/src/client/dist/spa/assets/use-onboarding-CNeLPDtv.js +2 -0
  88. package/src/client/dist/spa/assets/use-quasar-k24tGxE-.js +1 -0
  89. package/src/client/dist/spa/assets/{vue-i18n-cyuEW5NY.js → vue-i18n-Cq-KgjJC.js} +2 -2
  90. package/src/client/dist/spa/assets/{xml-BMuHSj3C.js → xml-CsKo4k8C.js} +1 -1
  91. package/src/client/dist/spa/assets/{yaml-BRigOMm_.js → yaml-X5yKmi6z.js} +1 -1
  92. package/src/client/dist/spa/index.html +13 -13
  93. package/src/client/dist/spa/assets/ActivityFeed-CrlJXGsr.js +0 -8
  94. package/src/client/dist/spa/assets/CreatePage-B7DVVS0u.js +0 -2
  95. package/src/client/dist/spa/assets/CreatePage-ZyBHUbl0.css +0 -1
  96. package/src/client/dist/spa/assets/HealthPage-BP_TiWeV.js +0 -1
  97. package/src/client/dist/spa/assets/MainLayout-BRV8i-6o.js +0 -37
  98. package/src/client/dist/spa/assets/QBtn-CLU6snCm.js +0 -1
  99. package/src/client/dist/spa/assets/QExpansionItem-BFqbf9C7.js +0 -1
  100. package/src/client/dist/spa/assets/QIcon-0rjEivgj.js +0 -1
  101. package/src/client/dist/spa/assets/QInput-Ciqjq5-e.js +0 -1
  102. package/src/client/dist/spa/assets/QItemSection-DZnyqPM3.js +0 -1
  103. package/src/client/dist/spa/assets/QList-MfhZa-uv.js +0 -1
  104. package/src/client/dist/spa/assets/QMenu-Cx3v9_6z.js +0 -1
  105. package/src/client/dist/spa/assets/QPage-CGYPttdA.js +0 -1
  106. package/src/client/dist/spa/assets/QRadio-BEsMlsx1.js +0 -1
  107. package/src/client/dist/spa/assets/QSpace-BrtkvWzZ.js +0 -1
  108. package/src/client/dist/spa/assets/QTooltip-DdpeVKGV.js +0 -1
  109. package/src/client/dist/spa/assets/SearchPage-MfSrrMo1.js +0 -1
  110. package/src/client/dist/spa/assets/SettingsPage-CR3kXpHd.js +0 -9
  111. package/src/client/dist/spa/assets/WorkspacePage-B4YnZ6re.css +0 -1
  112. package/src/client/dist/spa/assets/WorkspacePage-DUKKBulA.js +0 -4
  113. package/src/client/dist/spa/assets/build-path-tree-DOtGuDWX.js +0 -1
  114. package/src/client/dist/spa/assets/documents-qOarUoMj.js +0 -1
  115. package/src/client/dist/spa/assets/engineFeatures-DNZNjYIf.js +0 -1
  116. package/src/client/dist/spa/assets/expand-template-Fy2sU7vj.js +0 -1
  117. package/src/client/dist/spa/assets/i18n-CUkOJRYM.js +0 -1
  118. package/src/client/dist/spa/assets/index-D9yyMIdQ.js +0 -2
  119. package/src/client/dist/spa/assets/notifications-BzyK_kYE.js +0 -1
  120. package/src/client/dist/spa/assets/permissionModes-BBA8zBcf.js +0 -1
  121. package/src/client/dist/spa/assets/project-color-d_h7ZYoM.js +0 -1
  122. package/src/client/dist/spa/assets/purify.es-CxaFgMzS.js +0 -60
  123. package/src/client/dist/spa/assets/render-chat-markdown-B41YA3kF.js +0 -1
  124. package/src/client/dist/spa/assets/runtime-core.esm-bundler-DPcTPMmX.js +0 -1
  125. package/src/client/dist/spa/assets/use-checkbox-DzLCp4E3.js +0 -1
  126. package/src/client/dist/spa/assets/use-id-BQW6DfJU.js +0 -1
  127. package/src/client/dist/spa/assets/use-quasar-C5gKpYwL.js +0 -1
@@ -332,6 +332,39 @@ export function runMigrations(db) {
332
332
  db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, new Date().toISOString());
333
333
  }
334
334
  }
335
+ // Soft downgrade guard: if the database carries a migration version this
336
+ // build doesn't know about, it was migrated by a newer Kōbō. Forward-only
337
+ // migrations have no `down` step — warn loudly but let the app continue.
338
+ const maxVersion = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get().v ?? 0;
339
+ if (maxVersion > SCHEMA_VERSION) {
340
+ console.warn(`[kobo] ⚠ Database schema version (${maxVersion}) is higher than this Kōbō build supports ` +
341
+ `(${SCHEMA_VERSION}). The database was migrated by a newer version of Kōbō — running an older ` +
342
+ `build may cause errors. Update Kōbō, or restore a pre-downgrade backup from the Kōbō home directory.`);
343
+ }
344
+ }
345
+ /**
346
+ * Return the versions of registered migrations not yet applied to `db`, without
347
+ * applying them. Used to decide whether a pre-migration backup is warranted.
348
+ * Returns `[]` for a fresh / uninitialised database — there is no data at risk.
349
+ */
350
+ export function getPendingMigrations(db) {
351
+ const tableExists = (name) => db.prepare("SELECT count(*) AS c FROM sqlite_master WHERE type='table' AND name=?").get(name).c > 0;
352
+ let applied;
353
+ if (tableExists('schema_migrations')) {
354
+ applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
355
+ }
356
+ else if (tableExists('schema_version')) {
357
+ // Legacy single-row table: every version up to the stored one is applied.
358
+ const legacy = db.prepare('SELECT version FROM schema_version LIMIT 1').get()?.version ?? 0;
359
+ applied = new Set();
360
+ for (let v = 1; v <= legacy; v++)
361
+ applied.add(v);
362
+ }
363
+ else {
364
+ // Fresh / uninitialised DB — initSchema bootstraps it, nothing to back up.
365
+ return [];
366
+ }
367
+ return migrations.filter((m) => !applied.has(m.version)).map((m) => m.version);
335
368
  }
336
369
  /** Return the full migration history (for diagnostics / admin UI). */
337
370
  export function getMigrationHistory(db) {
@@ -5,10 +5,13 @@ import { serve } from '@hono/node-server';
5
5
  import { Hono } from 'hono';
6
6
  import WebSocket, { WebSocketServer } from 'ws';
7
7
  import { closeDb, getDb } from './db/index.js';
8
- import { runMigrations } from './db/migrations.js';
8
+ import { getPendingMigrations, runMigrations } from './db/migrations.js';
9
+ import changelogRouter from './routes/changelog.js';
9
10
  import devServerRouter from './routes/dev-server.js';
10
11
  import documentsRouter from './routes/documents.js';
11
12
  import { enginesRouter } from './routes/engines.js';
13
+ import exportRouter from './routes/export.js';
14
+ import fsRouter from './routes/fs.js';
12
15
  import gitRouter from './routes/git.js';
13
16
  import healthRouter from './routes/health.js';
14
17
  import imagesRouter from './routes/images.js';
@@ -25,7 +28,7 @@ import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb,
25
28
  import * as autoLoopService from './services/auto-loop-service.js';
26
29
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
27
30
  import * as cronService from './services/cron-service.js';
28
- import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
31
+ import { createDailyDbBackupIfNeeded, createPreMigrationBackup } from './services/db-backup-service.js';
29
32
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
30
33
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
31
34
  import * as quotaBackoffService from './services/quota-backoff-service.js';
@@ -39,6 +42,19 @@ import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/
39
42
  console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
40
43
  // Initialize DB + run migrations
41
44
  const db = getDb();
45
+ // Pre-migration backup: snapshot the DB before applying any pending schema
46
+ // migration so a botched upgrade can be rolled back manually. Best-effort —
47
+ // a backup failure must not block boot (the daily backup is a second net).
48
+ try {
49
+ const pending = getPendingMigrations(db);
50
+ if (pending.length > 0) {
51
+ const result = await createPreMigrationBackup(db, getDbPath(), `v${pending[pending.length - 1]}`);
52
+ console.log(`[kobo] Pre-migration backup before applying ${pending.length} migration(s): ${result.created}`);
53
+ }
54
+ }
55
+ catch (err) {
56
+ console.error('[kobo] Pre-migration backup failed (continuing — daily backup remains as fallback):', err);
57
+ }
42
58
  runMigrations(db);
43
59
  // Daily DB backup (best-effort, fire-and-forget — never blocks boot).
44
60
  // Creates a WAL-safe snapshot alongside kobo.db if no backup exists in the
@@ -75,11 +91,14 @@ app.route('/api/workspaces', imagesRouter);
75
91
  app.route('/api/notion', notionRouter);
76
92
  app.route('/api/sentry', sentryRouter);
77
93
  app.route('/api/git', gitRouter);
94
+ app.route('/api/fs', fsRouter);
95
+ app.route('/api/changelog', changelogRouter);
78
96
  app.route('/api/settings', settingsRouter);
79
97
  app.route('/api/dev-server', devServerRouter);
80
98
  app.route('/api/templates', templatesRouter);
81
99
  app.route('/api/usage', usageRoutes);
82
100
  app.route('/api/workspaces', documentsRouter);
101
+ app.route('/api/workspaces', exportRouter);
83
102
  app.route('/api/search', searchRouter);
84
103
  app.route('/api/health', healthRouter);
85
104
  app.route('/api/engines', enginesRouter);
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import { Hono } from 'hono';
3
+ import { getChangelogPath, getPackageVersion } from '../utils/paths.js';
4
+ /** Hono sub-router for the in-app "What's new" dialog. */
5
+ const app = new Hono();
6
+ /**
7
+ * Parse a Keep-a-Changelog markdown file into ordered version sections. Each
8
+ * `## <version>` heading starts a new entry; everything until the next heading
9
+ * is its notes. A leading `v` on the version is stripped.
10
+ */
11
+ export function parseChangelog(markdown) {
12
+ const entries = [];
13
+ let current = null;
14
+ for (const line of markdown.split('\n')) {
15
+ const heading = line.match(/^##\s+v?(\d+\.\d+\.\d+[\w.-]*)\s*$/);
16
+ if (heading) {
17
+ if (current)
18
+ entries.push({ version: current.version, notes: current.lines.join('\n').trim() });
19
+ current = { version: heading[1], lines: [] };
20
+ }
21
+ else if (current) {
22
+ current.lines.push(line);
23
+ }
24
+ }
25
+ if (current)
26
+ entries.push({ version: current.version, notes: current.lines.join('\n').trim() });
27
+ return entries;
28
+ }
29
+ // GET /api/changelog — current app version + parsed CHANGELOG.md sections,
30
+ // powering the "What's new" dialog shown after an update.
31
+ app.get('/', (c) => {
32
+ try {
33
+ let versions = [];
34
+ try {
35
+ versions = parseChangelog(fs.readFileSync(getChangelogPath(), 'utf-8'));
36
+ }
37
+ catch {
38
+ // No CHANGELOG.md shipped (or unreadable) — degrade to an empty list.
39
+ }
40
+ return c.json({ currentVersion: getPackageVersion(), versions });
41
+ }
42
+ catch (err) {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ return c.json({ error: message }, 500);
45
+ }
46
+ });
47
+ export default app;
@@ -0,0 +1,71 @@
1
+ import { Hono } from 'hono';
2
+ import { getDb } from '../db/index.js';
3
+ import * as workspaceService from '../services/workspace-service.js';
4
+ /** Hono sub-router for workspace data exports (mounted on /api/workspaces). */
5
+ const app = new Hono();
6
+ /** Quote a CSV cell when it contains a delimiter, quote, or newline (RFC 4180). */
7
+ function csvCell(value) {
8
+ return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
9
+ }
10
+ function csvRow(values) {
11
+ return values.map(csvCell).join(',');
12
+ }
13
+ /**
14
+ * Best-effort extraction of a human-readable text from an event payload.
15
+ * Different event types carry their text under different keys; the full
16
+ * payload is exported alongside, so this is a convenience column only.
17
+ */
18
+ function extractText(payload) {
19
+ try {
20
+ const parsed = JSON.parse(payload);
21
+ if (!parsed || typeof parsed !== 'object')
22
+ return '';
23
+ const obj = parsed;
24
+ for (const key of ['text', 'content', 'message']) {
25
+ const v = obj[key];
26
+ if (typeof v === 'string')
27
+ return v;
28
+ }
29
+ }
30
+ catch {
31
+ /* malformed payload — leave the text column empty */
32
+ }
33
+ return '';
34
+ }
35
+ /** Filesystem-safe slug for the download filename. */
36
+ function slugify(name) {
37
+ const slug = name
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9]+/g, '-')
40
+ .replace(/^-+|-+$/g, '');
41
+ return slug || 'workspace';
42
+ }
43
+ // GET /api/workspaces/:id/events.csv — export every ws_event of the workspace
44
+ // (all sessions, chronological) as a CSV file.
45
+ app.get('/:id/events.csv', (c) => {
46
+ try {
47
+ const id = c.req.param('id');
48
+ const workspace = workspaceService.getWorkspace(id);
49
+ if (!workspace) {
50
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
51
+ }
52
+ const rows = getDb()
53
+ .prepare('SELECT session_id, type, payload, created_at FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC')
54
+ .all(id);
55
+ const lines = [csvRow(['created_at', 'session_id', 'type', 'text', 'payload'])];
56
+ for (const r of rows) {
57
+ lines.push(csvRow([r.created_at, r.session_id ?? '', r.type, extractText(r.payload), r.payload]));
58
+ }
59
+ // Leading BOM so Excel reads UTF-8 correctly; CRLF line endings per RFC 4180.
60
+ const csv = `${lines.join('\r\n')}\r\n`;
61
+ return c.body(csv, 200, {
62
+ 'Content-Type': 'text/csv; charset=utf-8',
63
+ 'Content-Disposition': `attachment; filename="${slugify(workspace.name)}-events.csv"`,
64
+ });
65
+ }
66
+ catch (err) {
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ return c.json({ error: message }, 500);
69
+ }
70
+ });
71
+ export default app;
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { Hono } from 'hono';
5
+ /** Hono sub-router for local filesystem browsing (project folder picker). */
6
+ const app = new Hono();
7
+ // GET /api/fs/list-dirs?path=<dir> — list the immediate subdirectories of a
8
+ // directory. Defaults to the user's home directory when `path` is omitted.
9
+ // Local single-user dev tool: the user browses their own filesystem.
10
+ app.get('/list-dirs', (c) => {
11
+ try {
12
+ const requested = c.req.query('path');
13
+ const target = path.resolve(requested?.trim() ? requested : os.homedir());
14
+ let stat;
15
+ try {
16
+ stat = fs.statSync(target);
17
+ }
18
+ catch {
19
+ return c.json({ error: `Directory not found: ${target}` }, 404);
20
+ }
21
+ if (!stat.isDirectory()) {
22
+ return c.json({ error: `Not a directory: ${target}` }, 400);
23
+ }
24
+ let dirents;
25
+ try {
26
+ dirents = fs.readdirSync(target, { withFileTypes: true });
27
+ }
28
+ catch (err) {
29
+ const message = err instanceof Error ? err.message : String(err);
30
+ return c.json({ error: `Cannot read directory: ${message}` }, 403);
31
+ }
32
+ // Directories only, hidden ones excluded, sorted case-insensitively.
33
+ const entries = dirents
34
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
35
+ .map((d) => ({ name: d.name, path: path.join(target, d.name) }))
36
+ .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
37
+ const parent = path.dirname(target);
38
+ return c.json({
39
+ path: target,
40
+ parent: parent === target ? null : parent,
41
+ entries,
42
+ });
43
+ }
44
+ catch (err) {
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ return c.json({ error: message }, 500);
47
+ }
48
+ });
49
+ export default app;
@@ -1,7 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { getDb } from '../db/index.js';
3
3
  import { listOrphanWorktrees } from '../services/worktree-service.js';
4
- import { listBranches, listRemoteBranches } from '../utils/git-ops.js';
4
+ import { listBranches, listRemoteBranches, listWorktreeFiles } from '../utils/git-ops.js';
5
5
  /** Hono sub-router for git-related endpoints (branch listing). */
6
6
  const app = new Hono();
7
7
  // GET /api/git/branches?path=<repoPath> — list branches for a repo
@@ -20,6 +20,21 @@ app.get('/branches', (c) => {
20
20
  return c.json({ error: message }, 500);
21
21
  }
22
22
  });
23
+ // GET /api/git/files?path=<worktreePath> — list a worktree's files (tracked +
24
+ // untracked-but-not-ignored) for the chat's `@file` autocomplete.
25
+ app.get('/files', (c) => {
26
+ try {
27
+ const repoPath = c.req.query('path');
28
+ if (!repoPath) {
29
+ return c.json({ error: 'Missing required query parameter: path' }, 400);
30
+ }
31
+ return c.json({ files: listWorktreeFiles(repoPath) });
32
+ }
33
+ catch (err) {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ return c.json({ error: message }, 500);
36
+ }
37
+ });
23
38
  // GET /api/git/orphan-worktrees?projectPath=<path> — list worktrees of a
24
39
  // project that are NOT attached to any Kōbō workspace yet.
25
40
  app.get('/orphan-worktrees', (c) => {
@@ -5,7 +5,7 @@ import { getDb } from '../db/index.js';
5
5
  import { SCHEMA_VERSION } from '../db/migrations.js';
6
6
  import { resolveCodexBinary } from '../services/agent/engines/codex/spawn.js';
7
7
  import { getGlobalSettings, getProjectSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
8
- import { getDbPath, getKoboHome } from '../utils/paths.js';
8
+ import { getDbPath, getKoboHome, getPackageVersion } from '../utils/paths.js';
9
9
  import { slugifyProjectName } from '../utils/project-slug.js';
10
10
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
11
11
  const app = new Hono();
@@ -146,6 +146,7 @@ app.get('/report', (c) => {
146
146
  const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
147
147
  const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
148
148
  const report = {
149
+ version: getPackageVersion(),
149
150
  koboHome: home,
150
151
  db: {
151
152
  path: dbPath,
@@ -9,6 +9,7 @@ import { getDb } from '../db/index.js';
9
9
  import { migrationGuard } from '../middleware/migration-guard.js';
10
10
  import { listEngines } from '../services/agent/engines/registry.js';
11
11
  import * as agentManager from '../services/agent/orchestrator.js';
12
+ import * as archiveScriptService from '../services/archive-script-service.js';
12
13
  import * as autoLoopService from '../services/auto-loop-service.js';
13
14
  import * as cronService from '../services/cron-service.js';
14
15
  import * as devServerService from '../services/dev-server-service.js';
@@ -1694,6 +1695,8 @@ app.post('/:id/archive', migrationGuard, (c) => {
1694
1695
  }
1695
1696
  const updated = workspaceService.archiveWorkspace(id);
1696
1697
  wsService.emitEphemeral(id, 'workspace:archived', { workspace: updated });
1698
+ // Run the project's archive script (best-effort — never blocks the archive).
1699
+ archiveScriptService.onWorkspaceArchived(id);
1697
1700
  return c.json(updated);
1698
1701
  }
1699
1702
  catch (err) {
@@ -1721,86 +1724,126 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
1721
1724
  return c.json({ error: message }, 500);
1722
1725
  }
1723
1726
  });
1724
- // DELETE /api/workspaces/:id delete workspace
1725
- app.delete('/:id', migrationGuard, async (c) => {
1727
+ // Shared teardown for a single workspace: stops the agent, destroys the
1728
+ // terminal, removes the owned worktree, optionally deletes local/remote
1729
+ // branches, then deletes the DB row (cascades to tasks/sessions/events).
1730
+ // Every side-effect is best-effort — failures are collected as warnings
1731
+ // rather than thrown, so a bulk delete never aborts mid-batch. Returns the
1732
+ // list of user-facing warning messages (empty when everything was clean).
1733
+ function deleteWorkspaceWithSideEffects(workspace, opts) {
1734
+ // Stop agent if running (best-effort)
1726
1735
  try {
1727
- const id = c.req.param('id');
1728
- const workspace = workspaceService.getWorkspace(id);
1729
- if (!workspace) {
1730
- return c.json({ error: `Workspace '${id}' not found` }, 404);
1731
- }
1732
- // Parse optional body for branch deletion options
1733
- const body = await c.req
1734
- .json()
1735
- .catch(() => ({}));
1736
- // Stop agent if running (best-effort)
1736
+ agentManager.stopAgent(workspace.id);
1737
+ }
1738
+ catch {
1739
+ // Agent may not be running ignore
1740
+ }
1741
+ try {
1742
+ terminalService.destroyTerminal(workspace.id);
1743
+ }
1744
+ catch {
1745
+ // Terminal may not exist — ignore
1746
+ }
1747
+ // Collected best-effort warnings: the DB deletion always proceeds, but
1748
+ // side-effects (worktree, local/remote branches) can fail independently.
1749
+ // We surface a user-friendly message per failure so the UI can show a
1750
+ // sticky toast with a copy-pasteable recovery command — common case:
1751
+ // Docker leaves root-owned files inside the worktree, git worktree
1752
+ // remove fails with EACCES.
1753
+ const warnings = [];
1754
+ // Remove worktree (only if owned — for attached external worktrees we
1755
+ // never created the dir, so we must not delete it on the user's behalf).
1756
+ const worktreePath = workspace.worktreePath;
1757
+ if (workspace.worktreeOwned) {
1737
1758
  try {
1738
- agentManager.stopAgent(id);
1759
+ worktreeService.removeWorktree(workspace.projectPath, worktreePath);
1739
1760
  }
1740
- catch {
1741
- // Agent may not be running ignore
1761
+ catch (err) {
1762
+ const message = err instanceof Error ? err.message : String(err);
1763
+ console.error(`[workspaces] Failed to remove worktree: ${message}`);
1764
+ warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
1765
+ `Fix manually:\n` +
1766
+ ` sudo rm -rf '${worktreePath}'\n` +
1767
+ ` cd '${workspace.projectPath}' && git worktree prune\n` +
1768
+ `Reason: ${message}`);
1742
1769
  }
1770
+ }
1771
+ else {
1772
+ console.log(`[workspaces] keeping reused worktree on delete: ${worktreePath}`);
1773
+ }
1774
+ // Delete local branch if requested
1775
+ if (opts.deleteLocalBranch) {
1743
1776
  try {
1744
- terminalService.destroyTerminal(id);
1777
+ gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
1745
1778
  }
1746
- catch {
1747
- // Terminal may not exist ignore
1779
+ catch (err) {
1780
+ const message = err instanceof Error ? err.message : String(err);
1781
+ console.error(`[workspaces] Failed to delete local branch: ${message}`);
1782
+ warnings.push(`Failed to delete local branch '${workspace.workingBranch}'. Fix manually:\n` +
1783
+ ` cd '${workspace.projectPath}' && git branch -D '${workspace.workingBranch}'\n` +
1784
+ `Reason: ${message}`);
1748
1785
  }
1749
- // Collected best-effort warnings: the DB deletion always proceeds, but
1750
- // side-effects (worktree, local/remote branches) can fail independently.
1751
- // We surface a user-friendly message per failure so the UI can show a
1752
- // sticky toast with a copy-pasteable recovery command — common case:
1753
- // Docker leaves root-owned files inside the worktree, git worktree
1754
- // remove fails with EACCES.
1755
- const warnings = [];
1756
- // Remove worktree (only if owned — for attached external worktrees we
1757
- // never created the dir, so we must not delete it on the user's behalf).
1758
- const worktreePath = workspace.worktreePath;
1759
- if (workspace.worktreeOwned) {
1760
- try {
1761
- worktreeService.removeWorktree(workspace.projectPath, worktreePath);
1762
- }
1763
- catch (err) {
1764
- const message = err instanceof Error ? err.message : String(err);
1765
- console.error(`[workspaces] Failed to remove worktree: ${message}`);
1766
- warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
1767
- `Fix manually:\n` +
1768
- ` sudo rm -rf '${worktreePath}'\n` +
1769
- ` cd '${workspace.projectPath}' && git worktree prune\n` +
1770
- `Reason: ${message}`);
1771
- }
1786
+ }
1787
+ // Delete remote branch if requested
1788
+ if (opts.deleteRemoteBranch) {
1789
+ try {
1790
+ gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
1772
1791
  }
1773
- else {
1774
- console.log(`[workspaces] keeping reused worktree on delete: ${worktreePath}`);
1792
+ catch (err) {
1793
+ const message = err instanceof Error ? err.message : String(err);
1794
+ console.error(`[workspaces] Failed to delete remote branch: ${message}`);
1795
+ warnings.push(`Failed to delete remote branch '${workspace.workingBranch}'. Fix manually:\n` +
1796
+ ` cd '${workspace.projectPath}' && git push origin --delete '${workspace.workingBranch}'\n` +
1797
+ `Reason: ${message}`);
1775
1798
  }
1776
- // Delete local branch if requested
1777
- if (body.deleteLocalBranch) {
1799
+ }
1800
+ // Delete workspace from DB (cascades to tasks, sessions, events)
1801
+ workspaceService.deleteWorkspace(workspace.id);
1802
+ return warnings;
1803
+ }
1804
+ // DELETE /api/workspaces/archived — bulk-delete every archived workspace.
1805
+ // Must be declared BEFORE `DELETE /:id` or the dynamic segment captures it.
1806
+ // Each workspace teardown is isolated: an error on one is swallowed into the
1807
+ // warnings list and never aborts the batch.
1808
+ app.delete('/archived', migrationGuard, async (c) => {
1809
+ try {
1810
+ const body = await c.req
1811
+ .json()
1812
+ .catch(() => ({}));
1813
+ const archived = workspaceService.listArchivedWorkspaces();
1814
+ const warnings = [];
1815
+ let deleted = 0;
1816
+ for (const workspace of archived) {
1778
1817
  try {
1779
- gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
1818
+ warnings.push(...deleteWorkspaceWithSideEffects(workspace, body));
1819
+ deleted++;
1780
1820
  }
1781
1821
  catch (err) {
1782
1822
  const message = err instanceof Error ? err.message : String(err);
1783
- console.error(`[workspaces] Failed to delete local branch: ${message}`);
1784
- warnings.push(`Failed to delete local branch '${workspace.workingBranch}'. Fix manually:\n` +
1785
- ` cd '${workspace.projectPath}' && git branch -D '${workspace.workingBranch}'\n` +
1786
- `Reason: ${message}`);
1823
+ console.error(`[workspaces] Failed to delete archived workspace '${workspace.id}': ${message}`);
1824
+ warnings.push(`Failed to delete workspace '${workspace.name}': ${message}`);
1787
1825
  }
1788
1826
  }
1789
- // Delete remote branch if requested
1790
- if (body.deleteRemoteBranch) {
1791
- try {
1792
- gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
1793
- }
1794
- catch (err) {
1795
- const message = err instanceof Error ? err.message : String(err);
1796
- console.error(`[workspaces] Failed to delete remote branch: ${message}`);
1797
- warnings.push(`Failed to delete remote branch '${workspace.workingBranch}'. Fix manually:\n` +
1798
- ` cd '${workspace.projectPath}' && git push origin --delete '${workspace.workingBranch}'\n` +
1799
- `Reason: ${message}`);
1800
- }
1827
+ return c.json({ ok: true, deleted, warnings }, 200);
1828
+ }
1829
+ catch (err) {
1830
+ const message = err instanceof Error ? err.message : String(err);
1831
+ return c.json({ error: message }, 500);
1832
+ }
1833
+ });
1834
+ // DELETE /api/workspaces/:id delete workspace
1835
+ app.delete('/:id', migrationGuard, async (c) => {
1836
+ try {
1837
+ const id = c.req.param('id');
1838
+ const workspace = workspaceService.getWorkspace(id);
1839
+ if (!workspace) {
1840
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1801
1841
  }
1802
- // Delete workspace from DB (cascades to tasks, sessions, events)
1803
- workspaceService.deleteWorkspace(id);
1842
+ // Parse optional body for branch deletion options
1843
+ const body = await c.req
1844
+ .json()
1845
+ .catch(() => ({}));
1846
+ const warnings = deleteWorkspaceWithSideEffects(workspace, body);
1804
1847
  // When everything worked cleanly we keep the legacy 204 response so
1805
1848
  // existing clients aren't surprised by a JSON body. Warnings promote the
1806
1849
  // response to 200 so the body is readable.
@@ -4,6 +4,7 @@ import { getDb } from '../../db/index.js';
4
4
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
5
5
  import { unregisterProcess } from '../../utils/process-tracker.js';
6
6
  import * as autoLoopService from '../auto-loop-service.js';
7
+ import * as cleanupScriptService from '../cleanup-script-service.js';
7
8
  import * as cronService from '../cron-service.js';
8
9
  import * as quotaBackoffService from '../quota-backoff-service.js';
9
10
  import { getEffectiveSettings } from '../settings-service.js';
@@ -308,6 +309,10 @@ function readEffectiveSettingsSafe(projectPath) {
308
309
  sourceBranch: 'main',
309
310
  devServer: null,
310
311
  setupScript: '',
312
+ cleanupScript: '',
313
+ cleanupScriptMode: 'no-tasks',
314
+ cleanupScriptOnlyOnChanges: false,
315
+ archiveScript: '',
311
316
  notionStatusProperty: '',
312
317
  notionInProgressStatus: '',
313
318
  };
@@ -531,7 +536,12 @@ function handleEvent(workspaceId, agentSessionId, ev) {
531
536
  // cleared, next iteration will start fresh) — report 'completed' to
532
537
  // auto-loop so it continues.
533
538
  const effectiveReason = isResumeFailed ? 'completed' : ev.reason;
539
+ // Capture the auto-loop flag BEFORE autoLoopService.onSessionEnded —
540
+ // disable() clears it, and the cleanup hook needs to know whether this was
541
+ // a mid-loop session (never cleans) or a standalone one.
542
+ const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
534
543
  autoLoopService.onSessionEnded(workspaceId, effectiveReason, delta);
544
+ cleanupScriptService.onSessionEnded(workspaceId, effectiveReason, { wasAutoLoop });
535
545
  }
536
546
  if (ev.kind === 'session:user-input-requested') {
537
547
  if (ev.requestKind === 'question') {
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs';
2
+ import { getDb } from '../db/index.js';
3
+ import { slugifyProjectName } from '../utils/project-slug.js';
4
+ import { runScript, SCRIPT_TIMEOUT_MS } from '../utils/script-runner.js';
5
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
+ import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
7
+ function getRow(workspaceId) {
8
+ const db = getDb();
9
+ const row = db
10
+ .prepare(`SELECT id, name, project_path, working_branch, source_branch, worktree_path
11
+ FROM workspaces WHERE id = ?`)
12
+ .get(workspaceId);
13
+ return row ?? null;
14
+ }
15
+ /** Execute the archive script in a worktree, streaming `archive:*` WS events. */
16
+ export function runArchiveScript(workspaceId, worktreePath, script, env) {
17
+ return runScript({
18
+ workspaceId,
19
+ worktreePath,
20
+ script,
21
+ eventPrefix: 'archive',
22
+ tmpFileName: '.archive-script.tmp',
23
+ env,
24
+ timeoutMs: SCRIPT_TIMEOUT_MS,
25
+ });
26
+ }
27
+ /**
28
+ * Hook for the `POST /api/workspaces/:id/archive` route — runs the project's
29
+ * archive script (if configured) after a workspace is archived. The worktree is
30
+ * still on disk at archive time (archiving is a soft-delete). Best-effort: never
31
+ * blocks or fails the archive operation.
32
+ */
33
+ export function onWorkspaceArchived(workspaceId) {
34
+ try {
35
+ const row = getRow(workspaceId);
36
+ if (!row)
37
+ return;
38
+ const effective = getEffectiveSettings(row.project_path);
39
+ const script = effective.archiveScript;
40
+ if (!script.trim())
41
+ return; // empty = disabled
42
+ const global = getGlobalSettings();
43
+ const projectSettings = getProjectSettings(row.project_path);
44
+ const projectSlug = global.worktreesPrefixByProject
45
+ ? slugifyProjectName(projectSettings?.displayName ?? '', row.project_path)
46
+ : undefined;
47
+ const worktreePath = row.worktree_path ??
48
+ resolveWorkspaceWorktreePath(row.project_path, row.working_branch, global.worktreesPath, projectSlug);
49
+ if (!fs.existsSync(worktreePath)) {
50
+ console.warn(`[archive-script-service] worktree missing, skipping archive script: ${worktreePath}`);
51
+ return;
52
+ }
53
+ void runArchiveScript(workspaceId, worktreePath, script, {
54
+ workspaceName: row.name,
55
+ branchName: row.working_branch,
56
+ sourceBranch: row.source_branch,
57
+ projectPath: row.project_path,
58
+ }).catch((err) => {
59
+ console.error('[archive-script-service] runArchiveScript failed:', err);
60
+ });
61
+ }
62
+ catch (err) {
63
+ console.error('[archive-script-service] onWorkspaceArchived failed:', err);
64
+ }
65
+ }
@@ -4,6 +4,7 @@ import { getDb } from '../db/index.js';
4
4
  import { slugifyProjectName } from '../utils/project-slug.js';
5
5
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
6
  import * as orchestrator from './agent/orchestrator.js';
7
+ import * as cleanupScriptService from './cleanup-script-service.js';
7
8
  import * as settingsService from './settings-service.js';
8
9
  import { getSuitePrompts } from './skill-suite-prompts.js';
9
10
  import { emit, emitEphemeral } from './websocket-service.js';
@@ -82,6 +83,12 @@ export function disable(workspaceId, reason) {
82
83
  const db = getDb();
83
84
  db.prepare('UPDATE workspaces SET auto_loop = 0 WHERE id = ?').run(workspaceId);
84
85
  emitEphemeral(workspaceId, 'autoloop:disabled', { reason });
86
+ // The loop finished every task — run the project's cleanup script. Other
87
+ // disable reasons (stall / error / user-action) leave tasks unfinished, so
88
+ // they intentionally skip the cleanup.
89
+ if (reason === 'completed') {
90
+ cleanupScriptService.onAutoLoopCompleted(workspaceId);
91
+ }
85
92
  }
86
93
  /**
87
94
  * Route a `session:ended` event into the auto-loop state machine.