@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
@@ -0,0 +1,118 @@
1
+ import fs from 'node:fs';
2
+ import { getDb } from '../db/index.js';
3
+ import { worktreeHasChanges } from '../utils/git-ops.js';
4
+ import { slugifyProjectName } from '../utils/project-slug.js';
5
+ import { runScript, SCRIPT_TIMEOUT_MS } from '../utils/script-runner.js';
6
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
7
+ import { getEffectiveSettings, getGlobalSettings, getProjectSettings, } from './settings-service.js';
8
+ function getRow(workspaceId) {
9
+ const db = getDb();
10
+ const row = db
11
+ .prepare(`SELECT id, name, project_path, working_branch, source_branch, worktree_path, archived_at
12
+ FROM workspaces WHERE id = ?`)
13
+ .get(workspaceId);
14
+ return row ?? null;
15
+ }
16
+ function countPendingTasks(workspaceId) {
17
+ const db = getDb();
18
+ const row = db
19
+ .prepare("SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status != 'done'")
20
+ .get(workspaceId);
21
+ return row.c;
22
+ }
23
+ /**
24
+ * Pure decision: should the cleanup script run? Encodes the full matrix so it
25
+ * is unit-testable without a DB.
26
+ *
27
+ * - No script → never.
28
+ * - Auto-loop completion → always (every task is done by definition).
29
+ * - Otherwise the agent must have finished cleanly (`completed`).
30
+ * - A mid-loop `session:ended` (`wasAutoLoop`) never triggers — only the loop's
31
+ * completion does.
32
+ * - `no-tasks` mode additionally requires zero pending tasks; `idle` does not.
33
+ */
34
+ export function shouldRunCleanup(input) {
35
+ if (!input.script.trim())
36
+ return false;
37
+ if (input.autoLoopCompleted)
38
+ return true;
39
+ if (input.reason !== 'completed')
40
+ return false;
41
+ if (input.wasAutoLoop)
42
+ return false;
43
+ if (input.mode === 'no-tasks' && input.pendingTasks > 0)
44
+ return false;
45
+ return true;
46
+ }
47
+ /** Execute the cleanup script in a worktree, streaming `cleanup:*` WS events. */
48
+ export function runCleanupScript(workspaceId, worktreePath, script, env) {
49
+ return runScript({
50
+ workspaceId,
51
+ worktreePath,
52
+ script,
53
+ eventPrefix: 'cleanup',
54
+ tmpFileName: '.cleanup-script.tmp',
55
+ env,
56
+ timeoutMs: SCRIPT_TIMEOUT_MS,
57
+ });
58
+ }
59
+ function trigger(workspaceId, decision) {
60
+ try {
61
+ const row = getRow(workspaceId);
62
+ if (!row || row.archived_at)
63
+ return;
64
+ const effective = getEffectiveSettings(row.project_path);
65
+ if (!shouldRunCleanup({
66
+ reason: decision.reason,
67
+ wasAutoLoop: decision.wasAutoLoop,
68
+ autoLoopCompleted: decision.autoLoopCompleted,
69
+ script: effective.cleanupScript,
70
+ mode: effective.cleanupScriptMode,
71
+ pendingTasks: countPendingTasks(workspaceId),
72
+ })) {
73
+ return;
74
+ }
75
+ const global = getGlobalSettings();
76
+ const projectSettings = getProjectSettings(row.project_path);
77
+ const projectSlug = global.worktreesPrefixByProject
78
+ ? slugifyProjectName(projectSettings?.displayName ?? '', row.project_path)
79
+ : undefined;
80
+ const worktreePath = row.worktree_path ??
81
+ resolveWorkspaceWorktreePath(row.project_path, row.working_branch, global.worktreesPath, projectSlug);
82
+ if (!fs.existsSync(worktreePath)) {
83
+ console.warn(`[cleanup-script-service] worktree missing, skipping cleanup: ${worktreePath}`);
84
+ return;
85
+ }
86
+ // Optional gate: only run when the worktree has uncommitted changes.
87
+ if (effective.cleanupScriptOnlyOnChanges && !worktreeHasChanges(worktreePath)) {
88
+ return;
89
+ }
90
+ // Best-effort: never let a cleanup failure break the calling flow.
91
+ void runCleanupScript(workspaceId, worktreePath, effective.cleanupScript, {
92
+ workspaceName: row.name,
93
+ branchName: row.working_branch,
94
+ sourceBranch: row.source_branch,
95
+ projectPath: row.project_path,
96
+ }).catch((err) => {
97
+ console.error('[cleanup-script-service] runCleanupScript failed:', err);
98
+ });
99
+ }
100
+ catch (err) {
101
+ console.error('[cleanup-script-service] trigger failed:', err);
102
+ }
103
+ }
104
+ /**
105
+ * Hook for orchestrator's `session:ended` handler. `wasAutoLoop` MUST be
106
+ * captured before `auto-loop-service.onSessionEnded` runs, since `disable()`
107
+ * clears the `auto_loop` flag.
108
+ */
109
+ export function onSessionEnded(workspaceId, reason, opts) {
110
+ trigger(workspaceId, { reason, wasAutoLoop: opts.wasAutoLoop, autoLoopCompleted: false });
111
+ }
112
+ /**
113
+ * Hook for `auto-loop-service.disable()` with `reason='completed'` — the loop
114
+ * finished every task. Runs the cleanup regardless of trigger mode.
115
+ */
116
+ export function onAutoLoopCompleted(workspaceId) {
117
+ trigger(workspaceId, { reason: 'completed', wasAutoLoop: false, autoLoopCompleted: true });
118
+ }
@@ -34,6 +34,10 @@ export const DEFAULT_GIT_CONVENTIONS = `# Git conventions
34
34
  - Never skip hooks (--no-verify) unless the user explicitly asks
35
35
  - Always inspect \`git status\` and \`git diff\` before staging
36
36
  `;
37
+ const CLEANUP_SCRIPT_MODES = ['idle', 'no-tasks'];
38
+ export function isValidCleanupScriptMode(value) {
39
+ return typeof value === 'string' && CLEANUP_SCRIPT_MODES.includes(value);
40
+ }
37
41
  export const DEFAULT_PR_PROMPT_TEMPLATE = `A pull request has been opened: {{pr_url}} (#{{pr_number}})
38
42
 
39
43
  Context:
@@ -80,6 +84,39 @@ export const DEFAULT_WORKSPACE_TAGS = [
80
84
  'notion',
81
85
  'sentry',
82
86
  ];
87
+ /**
88
+ * Default git branch prefixes seeded on fresh install and on settings upgrade.
89
+ * Stored without the trailing `/` — it's a separator added at display time and
90
+ * when composing the working branch (`<prefix>/<slug>`). The first entry is the
91
+ * one pre-selected on the workspace creation page.
92
+ */
93
+ export const DEFAULT_BRANCH_PREFIXES = ['feature', 'fix', 'hotfix', 'chore', 'refactor', 'docs', 'test'];
94
+ /**
95
+ * Sanitize a raw branch-prefix list: trim, strip surrounding slashes, drop
96
+ * entries that aren't valid git branch segments, dedupe, cap length. Returns
97
+ * an empty array when nothing survives — callers decide the fallback.
98
+ */
99
+ export function sanitizeBranchPrefixes(input) {
100
+ if (!Array.isArray(input))
101
+ return [];
102
+ const seen = new Set();
103
+ const result = [];
104
+ for (const raw of input) {
105
+ if (typeof raw !== 'string')
106
+ continue;
107
+ const value = raw.trim().replace(/^\/+|\/+$/g, '');
108
+ if (value.length === 0 || value.length > 50)
109
+ continue;
110
+ // Allow only characters safe in a git branch name; reject `..` runs.
111
+ if (!/^[A-Za-z0-9._/-]+$/.test(value) || value.includes('..'))
112
+ continue;
113
+ if (seen.has(value))
114
+ continue;
115
+ seen.add(value);
116
+ result.push(value);
117
+ }
118
+ return result;
119
+ }
83
120
  const settingsMigrations = [
84
121
  {
85
122
  version: 1,
@@ -427,6 +464,72 @@ const settingsMigrations = [
427
464
  }
428
465
  },
429
466
  },
467
+ {
468
+ version: 26,
469
+ name: 'add-branch-prefixes',
470
+ migrate: ({ global }) => {
471
+ if (!Array.isArray(global.branchPrefixes)) {
472
+ global.branchPrefixes = [...DEFAULT_BRANCH_PREFIXES];
473
+ }
474
+ },
475
+ },
476
+ {
477
+ version: 27,
478
+ name: 'add-project-task-prompt',
479
+ migrate: ({ projects }) => {
480
+ for (const p of projects) {
481
+ if (typeof p.taskPromptTemplate !== 'string')
482
+ p.taskPromptTemplate = '';
483
+ }
484
+ },
485
+ },
486
+ {
487
+ version: 28,
488
+ name: 'add-cleanup-script',
489
+ migrate: ({ global, projects }) => {
490
+ if (typeof global.cleanupScript !== 'string')
491
+ global.cleanupScript = '';
492
+ if (!isValidCleanupScriptMode(global.cleanupScriptMode))
493
+ global.cleanupScriptMode = 'no-tasks';
494
+ for (const p of projects) {
495
+ if (typeof p.cleanupScript !== 'string')
496
+ p.cleanupScript = '';
497
+ // '' = inherit; any other value must be a valid mode.
498
+ if (p.cleanupScriptMode !== '' && !isValidCleanupScriptMode(p.cleanupScriptMode)) {
499
+ p.cleanupScriptMode = '';
500
+ }
501
+ }
502
+ },
503
+ },
504
+ {
505
+ version: 29,
506
+ name: 'add-archive-script',
507
+ migrate: ({ global, projects }) => {
508
+ if (typeof global.archiveScript !== 'string')
509
+ global.archiveScript = '';
510
+ for (const p of projects) {
511
+ if (typeof p.archiveScript !== 'string')
512
+ p.archiveScript = '';
513
+ }
514
+ },
515
+ },
516
+ {
517
+ version: 30,
518
+ name: 'add-global-setup-script',
519
+ migrate: ({ global }) => {
520
+ if (typeof global.setupScript !== 'string')
521
+ global.setupScript = '';
522
+ },
523
+ },
524
+ {
525
+ version: 31,
526
+ name: 'add-cleanup-script-only-on-changes',
527
+ migrate: ({ global }) => {
528
+ if (typeof global.cleanupScriptOnlyOnChanges !== 'boolean') {
529
+ global.cleanupScriptOnlyOnChanges = false;
530
+ }
531
+ },
532
+ },
430
533
  ];
431
534
  /** Current settings schema version — always equals the highest migration version. */
432
535
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -461,6 +564,11 @@ function defaultSettings() {
461
564
  notionInitialPromptTemplate: DEFAULT_NOTION_INITIAL_PROMPT,
462
565
  sentryInitialPromptTemplate: DEFAULT_SENTRY_INITIAL_PROMPT,
463
566
  gitConventions: DEFAULT_GIT_CONVENTIONS,
567
+ setupScript: '',
568
+ cleanupScript: '',
569
+ cleanupScriptMode: 'no-tasks',
570
+ cleanupScriptOnlyOnChanges: false,
571
+ archiveScript: '',
464
572
  editorCommand: '',
465
573
  browserNotifications: true,
466
574
  audioNotifications: true,
@@ -474,6 +582,7 @@ function defaultSettings() {
474
582
  notionMcpKey: '',
475
583
  sentryMcpKey: '',
476
584
  tags: [...DEFAULT_WORKSPACE_TAGS],
585
+ branchPrefixes: [...DEFAULT_BRANCH_PREFIXES],
477
586
  worktreesPath: WORKTREES_PATH,
478
587
  worktreesPrefixByProject: false,
479
588
  voiceEnabled: false,
@@ -510,6 +619,10 @@ function defaultProjectSettings(projectPath) {
510
619
  sentryInitialPromptTemplate: '',
511
620
  gitConventions: '',
512
621
  setupScript: '',
622
+ taskPromptTemplate: '',
623
+ cleanupScript: '',
624
+ cleanupScriptMode: '',
625
+ archiveScript: '',
513
626
  devServer: {
514
627
  startCommand: '',
515
628
  stopCommand: '',
@@ -586,6 +699,35 @@ function readSettings() {
586
699
  }
587
700
  return migrated;
588
701
  }
702
+ /** Number of `settings.json.backup-*` files kept; older ones are rotated out. */
703
+ const SETTINGS_BACKUP_KEEP = 5;
704
+ /**
705
+ * Keep only the `SETTINGS_BACKUP_KEEP` most recent `settings.json.backup-*`
706
+ * files in `dir`, deleting the rest. Best-effort — never throws.
707
+ */
708
+ function pruneSettingsBackups(dir) {
709
+ try {
710
+ const backups = fs
711
+ .readdirSync(dir)
712
+ .filter((f) => f.startsWith('settings.json.backup-'))
713
+ .map((f) => {
714
+ const full = path.join(dir, f);
715
+ return { path: full, mtimeMs: fs.statSync(full).mtimeMs };
716
+ })
717
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
718
+ for (const entry of backups.slice(SETTINGS_BACKUP_KEEP)) {
719
+ try {
720
+ fs.unlinkSync(entry.path);
721
+ }
722
+ catch (err) {
723
+ console.error(`[settings] Failed to prune backup ${entry.path}:`, err);
724
+ }
725
+ }
726
+ }
727
+ catch (err) {
728
+ console.error('[settings] Failed to prune settings backups:', err);
729
+ }
730
+ }
589
731
  function createSettingsBackupIfPresent() {
590
732
  if (!fs.existsSync(settingsFilePath))
591
733
  return;
@@ -594,6 +736,7 @@ function createSettingsBackupIfPresent() {
594
736
  settingsBackupSequence += 1;
595
737
  const backupPath = path.join(dir, `settings.json.backup-${stamp}-${settingsBackupSequence}`);
596
738
  fs.copyFileSync(settingsFilePath, backupPath);
739
+ pruneSettingsBackups(dir);
597
740
  }
598
741
  function writeSettings(settings, options) {
599
742
  ensureSettingsPathInitialized();
@@ -689,7 +832,11 @@ export function getEffectiveSettings(projectPath) {
689
832
  gitConventions: settings.global.gitConventions,
690
833
  sourceBranch: '',
691
834
  devServer: null,
692
- setupScript: '',
835
+ setupScript: settings.global.setupScript,
836
+ cleanupScript: settings.global.cleanupScript,
837
+ cleanupScriptMode: settings.global.cleanupScriptMode,
838
+ cleanupScriptOnlyOnChanges: settings.global.cleanupScriptOnlyOnChanges,
839
+ archiveScript: settings.global.archiveScript,
693
840
  notionStatusProperty: settings.global.notionStatusProperty,
694
841
  notionInProgressStatus: settings.global.notionInProgressStatus,
695
842
  };
@@ -710,7 +857,11 @@ export function getEffectiveSettings(projectPath) {
710
857
  gitConventions: project.gitConventions || settings.global.gitConventions,
711
858
  sourceBranch: project.defaultSourceBranch,
712
859
  devServer: project.devServer,
713
- setupScript: project.setupScript || '',
860
+ setupScript: project.setupScript || settings.global.setupScript,
861
+ cleanupScript: project.cleanupScript || settings.global.cleanupScript,
862
+ cleanupScriptMode: (project.cleanupScriptMode || settings.global.cleanupScriptMode),
863
+ cleanupScriptOnlyOnChanges: settings.global.cleanupScriptOnlyOnChanges,
864
+ archiveScript: project.archiveScript || settings.global.archiveScript,
714
865
  notionStatusProperty: settings.global.notionStatusProperty,
715
866
  notionInProgressStatus: settings.global.notionInProgressStatus,
716
867
  };
@@ -726,6 +877,13 @@ export function updateGlobalSettings(data) {
726
877
  delete data.skillSuite;
727
878
  }
728
879
  }
880
+ // Validate cleanupScriptMode: drop invalid values so the previous one stays.
881
+ if ('cleanupScriptMode' in data) {
882
+ if (!isValidCleanupScriptMode(data.cleanupScriptMode)) {
883
+ console.warn(`[settings] Invalid cleanupScriptMode rejected: ${data.cleanupScriptMode}`);
884
+ delete data.cleanupScriptMode;
885
+ }
886
+ }
729
887
  const allowedGlobalKeys = [
730
888
  'defaultModelByEngine',
731
889
  'dangerouslySkipPermissions',
@@ -734,6 +892,11 @@ export function updateGlobalSettings(data) {
734
892
  'notionInitialPromptTemplate',
735
893
  'sentryInitialPromptTemplate',
736
894
  'gitConventions',
895
+ 'setupScript',
896
+ 'cleanupScript',
897
+ 'cleanupScriptMode',
898
+ 'cleanupScriptOnlyOnChanges',
899
+ 'archiveScript',
737
900
  'editorCommand',
738
901
  'browserNotifications',
739
902
  'audioNotifications',
@@ -747,6 +910,7 @@ export function updateGlobalSettings(data) {
747
910
  'notionMcpKey',
748
911
  'sentryMcpKey',
749
912
  'tags',
913
+ 'branchPrefixes',
750
914
  'worktreesPath',
751
915
  'worktreesPrefixByProject',
752
916
  'voiceEnabled',
@@ -775,6 +939,12 @@ export function updateGlobalSettings(data) {
775
939
  .filter((t) => t.length > 0 && t.length <= 50)))
776
940
  : settings.global.tags;
777
941
  }
942
+ if (filtered.branchPrefixes !== undefined) {
943
+ // Drop invalid entries; never let the list collapse to empty (the creation
944
+ // page needs at least one prefix) — fall back to the previous value.
945
+ const sanitized = sanitizeBranchPrefixes(filtered.branchPrefixes);
946
+ filtered.branchPrefixes = sanitized.length > 0 ? sanitized : settings.global.branchPrefixes;
947
+ }
778
948
  if (filtered.audioNotificationVolume !== undefined) {
779
949
  const v = Number(filtered.audioNotificationVolume);
780
950
  filtered.audioNotificationVolume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
@@ -816,6 +986,14 @@ export function upsertProject(projectPath, data) {
816
986
  delete data.color;
817
987
  }
818
988
  }
989
+ // Validate cleanupScriptMode: '' means inherit; any other value must be a
990
+ // valid mode. Drop anything else so the stored value is preserved.
991
+ if ('cleanupScriptMode' in data) {
992
+ if (data.cleanupScriptMode !== '' && !isValidCleanupScriptMode(data.cleanupScriptMode)) {
993
+ console.warn(`[settings] Invalid cleanupScriptMode rejected for project '${projectPath}': ${data.cleanupScriptMode}`);
994
+ delete data.cleanupScriptMode;
995
+ }
996
+ }
819
997
  const allowedProjectKeys = [
820
998
  'displayName',
821
999
  'defaultSourceBranch',
@@ -827,6 +1005,10 @@ export function upsertProject(projectPath, data) {
827
1005
  'sentryInitialPromptTemplate',
828
1006
  'gitConventions',
829
1007
  'setupScript',
1008
+ 'taskPromptTemplate',
1009
+ 'cleanupScript',
1010
+ 'cleanupScriptMode',
1011
+ 'archiveScript',
830
1012
  'devServer',
831
1013
  'e2e',
832
1014
  'finalization',
@@ -1,85 +1,14 @@
1
- import { spawn } from 'node:child_process';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import * as wsService from './websocket-service.js';
5
- const SETUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
1
+ import { runScript, SCRIPT_TIMEOUT_MS } from '../utils/script-runner.js';
2
+ const SETUP_TIMEOUT_MS = SCRIPT_TIMEOUT_MS; // 5 minutes
6
3
  /** Execute a setup script in a worktree, streaming output via WebSocket. Resolves with the exit code. */
7
4
  export function runSetupScript(workspaceId, worktreePath, script, env, timeoutMs = SETUP_TIMEOUT_MS) {
8
- return new Promise((resolve) => {
9
- const scriptPath = path.join(worktreePath, '.ai', '.setup-script.tmp');
10
- fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
11
- fs.writeFileSync(scriptPath, script, { mode: 0o755 });
12
- const proc = spawn('bash', [scriptPath], {
13
- cwd: worktreePath,
14
- env: {
15
- ...process.env,
16
- WORKSPACE_ID: workspaceId,
17
- WORKSPACE_NAME: env?.workspaceName ?? '',
18
- BRANCH_NAME: env?.branchName ?? '',
19
- SOURCE_BRANCH: env?.sourceBranch ?? '',
20
- PROJECT_PATH: env?.projectPath ?? '',
21
- },
22
- stdio: ['ignore', 'pipe', 'pipe'],
23
- });
24
- const timeout = setTimeout(() => {
25
- proc.kill('SIGKILL');
26
- // Destroy pipes so the 'close' event fires immediately even if
27
- // child processes (e.g. sleep) inherited the file descriptors.
28
- proc.stdout?.destroy();
29
- proc.stderr?.destroy();
30
- wsService.emit(workspaceId, 'setup:output', {
31
- text: '[kobo] Setup script timed out after 5 minutes',
32
- });
33
- }, timeoutMs);
34
- const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[a-zA-Z]`, 'g');
35
- const stripAnsi = (s) => s.replace(ansiPattern, '');
36
- const emitLine = (text) => {
37
- const trimmed = stripAnsi(text).trim();
38
- if (trimmed) {
39
- wsService.emit(workspaceId, 'setup:output', { text: trimmed });
40
- }
41
- };
42
- proc.stdout.on('data', (data) => {
43
- for (const line of data.toString().split('\n')) {
44
- emitLine(line);
45
- }
46
- });
47
- proc.stderr.on('data', (data) => {
48
- for (const line of data.toString().split('\n')) {
49
- emitLine(line);
50
- }
51
- });
52
- let settled = false;
53
- const finish = (exitCode) => {
54
- if (settled)
55
- return;
56
- settled = true;
57
- clearTimeout(timeout);
58
- try {
59
- fs.unlinkSync(scriptPath);
60
- }
61
- catch {
62
- /* best-effort */
63
- }
64
- if (exitCode === 0) {
65
- wsService.emitEphemeral(workspaceId, 'setup:complete', {});
66
- }
67
- else {
68
- wsService.emitEphemeral(workspaceId, 'setup:error', {
69
- exitCode,
70
- message: `Setup script exited with code ${exitCode}`,
71
- });
72
- }
73
- resolve({ exitCode });
74
- };
75
- proc.on('error', (err) => {
76
- wsService.emit(workspaceId, 'setup:output', {
77
- text: `[kobo] Setup script failed to start: ${err.message}`,
78
- });
79
- finish(1);
80
- });
81
- proc.on('close', (code) => {
82
- finish(code ?? 1);
83
- });
5
+ return runScript({
6
+ workspaceId,
7
+ worktreePath,
8
+ script,
9
+ eventPrefix: 'setup',
10
+ tmpFileName: '.setup-script.tmp',
11
+ env,
12
+ timeoutMs,
84
13
  });
85
14
  }
@@ -538,6 +538,36 @@ export function moveWorktree(projectPath, oldPath, newPath) {
538
538
  git(projectPath, ['worktree', 'move', oldPath, newPath]);
539
539
  }
540
540
  /** List files changed between base and HEAD (committed), plus working tree changes. */
541
+ /**
542
+ * List the worktree's files — tracked plus untracked-but-not-git-ignored.
543
+ * Excludes `.git`, `node_modules`, and anything covered by `.gitignore`.
544
+ * Capped at `limit` entries to stay responsive on large monorepos.
545
+ * Returns [] on error (e.g. not a git repo).
546
+ */
547
+ export function listWorktreeFiles(worktreePath, limit = 5000) {
548
+ try {
549
+ const out = git(worktreePath, ['ls-files', '--cached', '--others', '--exclude-standard']);
550
+ if (!out)
551
+ return [];
552
+ const files = out.split('\n').filter((line) => line.length > 0);
553
+ return files.length > limit ? files.slice(0, limit) : files;
554
+ }
555
+ catch {
556
+ return [];
557
+ }
558
+ }
559
+ /**
560
+ * True when the worktree has any uncommitted change — modified, added, deleted
561
+ * or untracked files. Returns false on error (e.g. not a git repo).
562
+ */
563
+ export function worktreeHasChanges(worktreePath) {
564
+ try {
565
+ return git(worktreePath, ['status', '--porcelain']).length > 0;
566
+ }
567
+ catch {
568
+ return false;
569
+ }
570
+ }
541
571
  export function getChangedFiles(repoPath, base, includeUntracked = false) {
542
572
  const ref = resolveBase(repoPath, base);
543
573
  const files = [];
@@ -132,6 +132,10 @@ export function getPackageVersion() {
132
132
  _cachedVersion = pkg.version;
133
133
  return _cachedVersion;
134
134
  }
135
+ /** Absolute path to the root CHANGELOG.md (read by the "What's new" dialog). */
136
+ export function getChangelogPath() {
137
+ return getPackageAssetPath('CHANGELOG.md');
138
+ }
135
139
  /**
136
140
  * Absolute path to the built Quasar SPA (src/client/dist/spa). Returns null
137
141
  * if the SPA has not been built yet.
@@ -0,0 +1,96 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import * as wsService from '../services/websocket-service.js';
5
+ /** Default wall-clock budget for a user script before it is force-killed. */
6
+ export const SCRIPT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
7
+ /**
8
+ * Execute a user-provided bash script inside a worktree, streaming stdout/stderr
9
+ * line-by-line over WebSocket. Resolves with the exit code — never rejects.
10
+ * Shared mechanism behind the setup and cleanup script services.
11
+ */
12
+ export function runScript(opts) {
13
+ const { workspaceId, worktreePath, script, eventPrefix, tmpFileName, env } = opts;
14
+ const timeoutMs = opts.timeoutMs ?? SCRIPT_TIMEOUT_MS;
15
+ return new Promise((resolve) => {
16
+ const scriptPath = path.join(worktreePath, '.ai', tmpFileName);
17
+ fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
18
+ fs.writeFileSync(scriptPath, script, { mode: 0o755 });
19
+ const proc = spawn('bash', [scriptPath], {
20
+ cwd: worktreePath,
21
+ env: {
22
+ ...process.env,
23
+ WORKSPACE_ID: workspaceId,
24
+ WORKSPACE_NAME: env?.workspaceName ?? '',
25
+ BRANCH_NAME: env?.branchName ?? '',
26
+ SOURCE_BRANCH: env?.sourceBranch ?? '',
27
+ PROJECT_PATH: env?.projectPath ?? '',
28
+ },
29
+ stdio: ['ignore', 'pipe', 'pipe'],
30
+ });
31
+ const timeout = setTimeout(() => {
32
+ proc.kill('SIGKILL');
33
+ // Destroy pipes so the 'close' event fires immediately even if
34
+ // child processes (e.g. sleep) inherited the file descriptors.
35
+ proc.stdout?.destroy();
36
+ proc.stderr?.destroy();
37
+ wsService.emit(workspaceId, `${eventPrefix}:output`, {
38
+ text: `[kobo] Script timed out after ${Math.round(timeoutMs / 60000)} minutes`,
39
+ });
40
+ }, timeoutMs);
41
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[a-zA-Z]`, 'g');
42
+ const stripAnsi = (s) => s.replace(ansiPattern, '');
43
+ // Track whether the script printed anything — lets the UI show a terse
44
+ // "Done" instead of a near-empty card when a script runs silently.
45
+ let outputEmitted = false;
46
+ const emitLine = (text) => {
47
+ const trimmed = stripAnsi(text).trim();
48
+ if (trimmed) {
49
+ outputEmitted = true;
50
+ wsService.emit(workspaceId, `${eventPrefix}:output`, { text: trimmed });
51
+ }
52
+ };
53
+ proc.stdout.on('data', (data) => {
54
+ for (const line of data.toString().split('\n')) {
55
+ emitLine(line);
56
+ }
57
+ });
58
+ proc.stderr.on('data', (data) => {
59
+ for (const line of data.toString().split('\n')) {
60
+ emitLine(line);
61
+ }
62
+ });
63
+ let settled = false;
64
+ const finish = (exitCode) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ clearTimeout(timeout);
69
+ try {
70
+ fs.unlinkSync(scriptPath);
71
+ }
72
+ catch {
73
+ /* best-effort */
74
+ }
75
+ if (exitCode === 0) {
76
+ wsService.emitEphemeral(workspaceId, `${eventPrefix}:complete`, { hadOutput: outputEmitted });
77
+ }
78
+ else {
79
+ wsService.emitEphemeral(workspaceId, `${eventPrefix}:error`, {
80
+ exitCode,
81
+ message: `Script exited with code ${exitCode}`,
82
+ });
83
+ }
84
+ resolve({ exitCode });
85
+ };
86
+ proc.on('error', (err) => {
87
+ wsService.emit(workspaceId, `${eventPrefix}:output`, {
88
+ text: `[kobo] Script failed to start: ${err.message}`,
89
+ });
90
+ finish(1);
91
+ });
92
+ proc.on('close', (code) => {
93
+ finish(code ?? 1);
94
+ });
95
+ });
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.7.13",
3
+ "version": "1.7.15",
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",
@@ -13,6 +13,7 @@
13
13
  "src/mcp-server/",
14
14
  "AGENTS.md",
15
15
  "README.md",
16
+ "CHANGELOG.md",
16
17
  "LICENSE"
17
18
  ],
18
19
  "publishConfig": {