@loicngr/kobo 1.7.14 → 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 (126) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +5 -3
  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/workspaces.js +107 -64
  10. package/dist/server/services/agent/orchestrator.js +10 -0
  11. package/dist/server/services/archive-script-service.js +65 -0
  12. package/dist/server/services/auto-loop-service.js +7 -0
  13. package/dist/server/services/cleanup-script-service.js +118 -0
  14. package/dist/server/services/settings-service.js +184 -2
  15. package/dist/server/services/setup-script-service.js +10 -81
  16. package/dist/server/utils/git-ops.js +30 -0
  17. package/dist/server/utils/paths.js +4 -0
  18. package/dist/server/utils/script-runner.js +96 -0
  19. package/package.json +2 -1
  20. package/src/client/dist/spa/assets/ActivityFeed-DU6lDEP0.js +8 -0
  21. package/src/client/dist/spa/assets/{ActivityFeed-WjiQ9716.css → ActivityFeed-yUMQhnW4.css} +1 -1
  22. package/src/client/dist/spa/assets/{ClosePopup-C5EF3QrA.js → ClosePopup-CxvZA3ft.js} +1 -1
  23. package/src/client/dist/spa/assets/CreatePage-CdZr7f3j.js +2 -0
  24. package/src/client/dist/spa/assets/CreatePage-DgHjL4cZ.css +1 -0
  25. package/src/client/dist/spa/assets/{DiffViewer-B-pWVglv.js → DiffViewer-m801GPfI.js} +3 -3
  26. package/src/client/dist/spa/assets/HealthPage-z1uIOpYk.js +1 -0
  27. package/src/client/dist/spa/assets/{MainLayout-Bp0oVWa-.css → MainLayout-BJmBXwYn.css} +1 -1
  28. package/src/client/dist/spa/assets/MainLayout-oJdQ-QKM.js +37 -0
  29. package/src/client/dist/spa/assets/{QBadge-u0mEz_W1.js → QBadge-NEwszYs7.js} +1 -1
  30. package/src/client/dist/spa/assets/QBanner-Jsq4uJZs.js +1 -0
  31. package/src/client/dist/spa/assets/QBtn-CoU-UC_j.js +1 -0
  32. package/src/client/dist/spa/assets/{QCheckbox-skYuqkHX.js → QCheckbox-Cq2STfHp.js} +1 -1
  33. package/src/client/dist/spa/assets/{QChip-DSMeriN6.js → QChip-DnJyQVs2.js} +2 -2
  34. package/src/client/dist/spa/assets/QExpansionItem-BTd5m2yV.js +1 -0
  35. package/src/client/dist/spa/assets/QIcon-BmEX2rXO.js +1 -0
  36. package/src/client/dist/spa/assets/QInput-D0t39uK_.js +1 -0
  37. package/src/client/dist/spa/assets/{QItemLabel-B0tYxHQg.js → QItemLabel-Btqw0P7M.js} +1 -1
  38. package/src/client/dist/spa/assets/QItemSection-DRg-QuAD.js +1 -0
  39. package/src/client/dist/spa/assets/QList-B3TuWSqL.js +1 -0
  40. package/src/client/dist/spa/assets/QMenu-C2Wwwf2E.js +1 -0
  41. package/src/client/dist/spa/assets/QPage-DO_bQyV_.js +1 -0
  42. package/src/client/dist/spa/assets/QRadio-M9mC5jZy.js +1 -0
  43. package/src/client/dist/spa/assets/QScrollArea-A1wI0IXU.js +1 -0
  44. package/src/client/dist/spa/assets/QSpace-DONPiIes.js +1 -0
  45. package/src/client/dist/spa/assets/{QSpinnerDots-CluOpUgq.js → QSpinnerDots-DspFKwCZ.js} +1 -1
  46. package/src/client/dist/spa/assets/{QToggle-aBvIHg6j.js → QToggle-DBzTAIbK.js} +1 -1
  47. package/src/client/dist/spa/assets/QTooltip-Bfdmzm_m.js +1 -0
  48. package/src/client/dist/spa/assets/SearchPage-ChmKHNKn.js +1 -0
  49. package/src/client/dist/spa/assets/SettingsPage-B59LoCos.js +9 -0
  50. package/src/client/dist/spa/assets/{SettingsPage-B_qPRSDH.css → SettingsPage-BJLyYrBN.css} +1 -1
  51. package/src/client/dist/spa/assets/{TouchPan-DPBZDRzA.js → TouchPan-BIE5rs7U.js} +1 -1
  52. package/src/client/dist/spa/assets/WorkspacePage-Bj1PJSWT.js +4 -0
  53. package/src/client/dist/spa/assets/WorkspacePage-tFBswKV9.css +1 -0
  54. package/src/client/dist/spa/assets/build-path-tree-BGUV3nY1.js +1 -0
  55. package/src/client/dist/spa/assets/{cssMode-D5ZOAlu6.js → cssMode-BU4X8R6a.js} +1 -1
  56. package/src/client/dist/spa/assets/documents-B3nitIYF.js +1 -0
  57. package/src/client/dist/spa/assets/{editor.api-CTE0T74t.js → editor.api-B4xBDzmJ.js} +1 -1
  58. package/src/client/dist/spa/assets/{editor.main-Buyn4kmD.js → editor.main-CSZRkloL.js} +3 -3
  59. package/src/client/dist/spa/assets/engineFeatures-CLOVr5b4.js +1 -0
  60. package/src/client/dist/spa/assets/expand-template-BxUkuL5g.js +1 -0
  61. package/src/client/dist/spa/assets/{formatters-CgfY9uSI.js → formatters-9dcj2tyJ.js} +1 -1
  62. package/src/client/dist/spa/assets/{freemarker2-BqQ6Og68.js → freemarker2-DRz20wAV.js} +1 -1
  63. package/src/client/dist/spa/assets/{handlebars-DuIKUFde.js → handlebars-C0dsvPnC.js} +1 -1
  64. package/src/client/dist/spa/assets/{html-BHWxRlmb.js → html-Cqvj1pWs.js} +1 -1
  65. package/src/client/dist/spa/assets/{htmlMode-C-SjHXMn.js → htmlMode-BTHNvkm6.js} +1 -1
  66. package/src/client/dist/spa/assets/i18n-D1I-Us2H.js +1 -0
  67. package/src/client/dist/spa/assets/{index-5ydpLSpt.css → index-ClabzOPc.css} +1 -1
  68. package/src/client/dist/spa/assets/index-KABmOIkF.js +2 -0
  69. package/src/client/dist/spa/assets/{javascript-ukUWOwiC.js → javascript-C8n3U02v.js} +1 -1
  70. package/src/client/dist/spa/assets/{jsonMode-C_hd-E3X.js → jsonMode-C3AFxQ6K.js} +1 -1
  71. package/src/client/dist/spa/assets/{kobo-commands-Bh5k0Smw.js → kobo-commands-BuxgteGZ.js} +1 -1
  72. package/src/client/dist/spa/assets/{liquid-B6flz_LR.js → liquid-C4wtUDrJ.js} +1 -1
  73. package/src/client/dist/spa/assets/{mdx-F-Hst5AO.js → mdx-CaT1p1F2.js} +1 -1
  74. package/src/client/dist/spa/assets/{monaco.contribution-C8xDzD2M.js → monaco.contribution-CJg5GKVf.js} +2 -2
  75. package/src/client/dist/spa/assets/notifications-BC6en6Lt.js +1 -0
  76. package/src/client/dist/spa/assets/permissionModes-BQHBTBwa.js +1 -0
  77. package/src/client/dist/spa/assets/{python-Bl42f-yd.js → python-Cj54W2Tg.js} +1 -1
  78. package/src/client/dist/spa/assets/{razor-Dx7_W971.js → razor-D3gJxoX_.js} +1 -1
  79. package/src/client/dist/spa/assets/render-chat-markdown-DxEHr3lW.js +60 -0
  80. package/src/client/dist/spa/assets/runtime-core.esm-bundler-D_RRiKBh.js +1 -0
  81. package/src/client/dist/spa/assets/{tsMode-CO3Uuszk.js → tsMode-B6S4PLWH.js} +1 -1
  82. package/src/client/dist/spa/assets/{typescript-BxouTBRr.js → typescript-Ca8AEX3t.js} +1 -1
  83. package/src/client/dist/spa/assets/use-checkbox-DnSuDqo2.js +1 -0
  84. package/src/client/dist/spa/assets/use-id-BCnfiBjU.js +1 -0
  85. package/src/client/dist/spa/assets/use-onboarding-B3l7mx48.css +1 -0
  86. package/src/client/dist/spa/assets/use-onboarding-CNeLPDtv.js +2 -0
  87. package/src/client/dist/spa/assets/use-quasar-k24tGxE-.js +1 -0
  88. package/src/client/dist/spa/assets/{vue-i18n-cyuEW5NY.js → vue-i18n-Cq-KgjJC.js} +2 -2
  89. package/src/client/dist/spa/assets/{xml-W_9Wtmsi.js → xml-CsKo4k8C.js} +1 -1
  90. package/src/client/dist/spa/assets/{yaml-ByUJ6m8w.js → yaml-X5yKmi6z.js} +1 -1
  91. package/src/client/dist/spa/index.html +13 -13
  92. package/src/client/dist/spa/assets/ActivityFeed-LlMDs201.js +0 -8
  93. package/src/client/dist/spa/assets/CreatePage-ZyBHUbl0.css +0 -1
  94. package/src/client/dist/spa/assets/CreatePage-kLDqaPDs.js +0 -2
  95. package/src/client/dist/spa/assets/HealthPage-C3wPvMBd.js +0 -1
  96. package/src/client/dist/spa/assets/MainLayout-BQc7MqCn.js +0 -37
  97. package/src/client/dist/spa/assets/QBtn-CLU6snCm.js +0 -1
  98. package/src/client/dist/spa/assets/QExpansionItem-BFqbf9C7.js +0 -1
  99. package/src/client/dist/spa/assets/QIcon-0rjEivgj.js +0 -1
  100. package/src/client/dist/spa/assets/QInput-Ciqjq5-e.js +0 -1
  101. package/src/client/dist/spa/assets/QItemSection-DZnyqPM3.js +0 -1
  102. package/src/client/dist/spa/assets/QList-MfhZa-uv.js +0 -1
  103. package/src/client/dist/spa/assets/QMenu-Cx3v9_6z.js +0 -1
  104. package/src/client/dist/spa/assets/QPage-CGYPttdA.js +0 -1
  105. package/src/client/dist/spa/assets/QRadio-BEsMlsx1.js +0 -1
  106. package/src/client/dist/spa/assets/QSpace-BrtkvWzZ.js +0 -1
  107. package/src/client/dist/spa/assets/QTooltip-DdpeVKGV.js +0 -1
  108. package/src/client/dist/spa/assets/SearchPage-DifF1k07.js +0 -1
  109. package/src/client/dist/spa/assets/SettingsPage-DfNUJb99.js +0 -9
  110. package/src/client/dist/spa/assets/WorkspacePage-B-E0Ry6O.js +0 -4
  111. package/src/client/dist/spa/assets/WorkspacePage-B4YnZ6re.css +0 -1
  112. package/src/client/dist/spa/assets/build-path-tree-DOtGuDWX.js +0 -1
  113. package/src/client/dist/spa/assets/documents-qOarUoMj.js +0 -1
  114. package/src/client/dist/spa/assets/engineFeatures-DYvwpYlV.js +0 -1
  115. package/src/client/dist/spa/assets/expand-template-BBEWjwKv.js +0 -1
  116. package/src/client/dist/spa/assets/i18n-CpmhEjSj.js +0 -1
  117. package/src/client/dist/spa/assets/index-DRHsozfK.js +0 -2
  118. package/src/client/dist/spa/assets/notifications-BzyK_kYE.js +0 -1
  119. package/src/client/dist/spa/assets/permissionModes-B1595Yfm.js +0 -1
  120. package/src/client/dist/spa/assets/project-color-d_h7ZYoM.js +0 -1
  121. package/src/client/dist/spa/assets/purify.es-DfpOsee9.js +0 -60
  122. package/src/client/dist/spa/assets/render-chat-markdown-CeI636ZV.js +0 -1
  123. package/src/client/dist/spa/assets/runtime-core.esm-bundler-DPcTPMmX.js +0 -1
  124. package/src/client/dist/spa/assets/use-checkbox-DzLCp4E3.js +0 -1
  125. package/src/client/dist/spa/assets/use-id-BQW6DfJU.js +0 -1
  126. package/src/client/dist/spa/assets/use-quasar-C5gKpYwL.js +0 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to Kōbō are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
+ section — the in-app "What's new" dialog reads this file.
6
+
7
+ ## 1.7.15
8
+
9
+ - docs: document new settings and features
10
+ - build(release): generate changelog section in version bump
11
+ - feat(onboarding): guided tour and what's-new dialog
12
+ - feat(settings): scripts, branch prefixes and project cards
13
+ - feat(workspaces): bulk-delete archived workspaces and fix flat sort
14
+ - feat(create): per-project task prompt template
15
+ - feat(health): show schema and settings migration versions
16
+ - feat(chat): dedicated script cards in the conversation feed
17
+ - feat(chat): @-mention file autocomplete with fuzzy matching
18
+ - feat(export): CSV export of workspace session events
19
+ - feat(server): lifecycle scripts, bulk delete and migration safety
20
+
21
+ ## 1.7.14
22
+
23
+ - Show the Kōbō version in the Health page Environment card.
24
+ - Document the `SERVER_PORT` / `PORT` overrides and fix the default port.
25
+ - Split the configuration reference into a dedicated `CONFIGURATION.md`.
package/README.md CHANGED
@@ -15,12 +15,14 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
15
15
 
16
16
  - **Isolated worktrees** — each workspace is a dedicated git worktree on its own branch; parallel sessions never collide.
17
17
  - **Two agent engines** — Claude Code (via `@anthropic-ai/claude-agent-sdk`) and OpenAI Codex (via `codex app-server`), chosen per workspace.
18
- - **Live chat** — streaming text, reasoning blocks, inline Edit/Write diffs, per-turn cards, infinite scrollback.
18
+ - **Live chat** — streaming text, reasoning blocks, inline Edit/Write diffs, per-turn cards, infinite scrollback; `/` autocompletes skills & commands and `@` fuzzy-autocompletes worktree file paths; every workspace's session events are exportable to CSV.
19
19
  - **Task tracking** — per-workspace MCP server (`kobo-tasks`) lets the agent manage its own tasks, acceptance criteria, and live status.
20
20
  - **Git panel** — Monaco-based diff viewer, inline conflict resolution, `Sync` / `Push` / `Open PR` wired to the `gh` CLI.
21
21
  - **Auto-loop** — opt-in mode that walks the task list, spawning a fresh session per task and stopping on completion, stall, or error.
22
22
  - **Quota-aware** — 5-hour / 7-day Claude usage and Codex rate-limit buckets in the footer; sessions auto-resume after a rate-limit reset.
23
- - **Persistent scheduling** — wakeups and crons survive restarts; the scheduler re-arms them at boot with skip-missed semantics.
23
+ - **Scheduled wakeups** — the agent schedules a one-shot wake-up via the `ScheduleWakeup` tool; Kōbō persists it across restarts, shows a live countdown, and re-invokes the agent with the stored prompt at the chosen time.
24
+ - **Cron schedules** — recurring per-workspace triggers the agent registers through MCP tools (`cron_create` / `cron_delete` / `cron_list`); each tick resumes the workspace session (skipped if already active), and schedules are re-armed at boot with skip-missed semantics.
25
+ - **Lifecycle scripts** — shell scripts run automatically at key moments: **setup** (worktree created), **cleanup** (session ended), **archive** (workspace archived). Configured globally or per project, with their output streamed into the chat.
24
26
  - **Optional integrations** — Notion (import missions), Sentry (fix from issue URL), local voice transcription (whisper.cpp).
25
27
 
26
28
  ## Quick start
@@ -63,7 +65,7 @@ The most common knobs:
63
65
  | `NOTION_API_TOKEN` | — | Notion integration token |
64
66
  | `OPENAI_API_KEY` | — | Codex engine credential (alternative to `codex login`) |
65
67
 
66
- Per-project settings (worktree path, dev server commands, E2E framework, prompt templates, git conventions) are edited in **Settings** at runtime.
68
+ Global and per-project settings (worktree path, dev server commands, E2E framework, prompt templates, git conventions, branch prefixes, lifecycle scripts, task prompt) are edited in **Settings** at runtime — per-project values inherit from the global ones when left empty.
67
69
 
68
70
  The full reference — every env var, every setting key, MCP server registration, Notion / Sentry / Voice setup — is in [`CONFIGURATION.md`](./CONFIGURATION.md).
69
71
 
@@ -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) => {
@@ -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') {