@loicngr/kobo 1.5.6 → 1.5.7

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 (179) hide show
  1. package/dist/server/db/migrations.js +14 -0
  2. package/dist/server/db/schema.js +2 -0
  3. package/dist/server/index.js +17 -1
  4. package/dist/server/routes/dev-server.js +1 -1
  5. package/dist/server/routes/health.js +95 -0
  6. package/dist/server/routes/search.js +33 -0
  7. package/dist/server/routes/settings.js +30 -0
  8. package/dist/server/routes/workspaces.js +46 -0
  9. package/dist/server/services/db-backup-service.js +66 -0
  10. package/dist/server/services/dev-server-service.js +24 -1
  11. package/dist/server/services/search-service.js +102 -0
  12. package/dist/server/services/settings-service.js +73 -6
  13. package/dist/server/services/templates-service.js +31 -0
  14. package/dist/server/services/workspace-service.js +54 -0
  15. package/package.json +1 -1
  16. package/src/client/dist/spa/assets/ActivityFeed-Dc1oLbwJ.js +10 -0
  17. package/src/client/dist/spa/assets/ClosePopup-CdSn7HO8.js +1 -0
  18. package/src/client/dist/spa/assets/CreatePage-BDKfkW-N.js +2 -0
  19. package/src/client/dist/spa/assets/CreatePage-dMi4xVYN.css +1 -0
  20. package/src/client/dist/spa/assets/DiffViewer-DZ9h2M2n.js +2 -0
  21. package/src/client/dist/spa/assets/HealthPage-BkqexlJb.js +1 -0
  22. package/src/client/dist/spa/assets/{MainLayout-DB5sQk0j.js → MainLayout-VxUBOt-P.js} +17 -17
  23. package/src/client/dist/spa/assets/MainLayout-rVleAIBi.css +1 -0
  24. package/src/client/dist/spa/assets/QBadge-Bvh-hQ8K.js +1 -0
  25. package/src/client/dist/spa/assets/QBtn-BsD8vrWq.js +1 -0
  26. package/src/client/dist/spa/assets/QDialog-CkbLS1If.js +1 -0
  27. package/src/client/dist/spa/assets/QExpansionItem-C735ptO9.js +1 -0
  28. package/src/client/dist/spa/assets/QItem-DfoP6eYj.js +1 -0
  29. package/src/client/dist/spa/assets/QList-D80ms7bw.js +1 -0
  30. package/src/client/dist/spa/assets/QMenu-DU-wiY_A.js +1 -0
  31. package/src/client/dist/spa/assets/QPage-BKY2-sf-.js +1 -0
  32. package/src/client/dist/spa/assets/QSpace-C5Ebr0vq.js +1 -0
  33. package/src/client/dist/spa/assets/QSpinner-CliSLjf8.js +1 -0
  34. package/src/client/dist/spa/assets/QSpinnerDots-Dp12eHrB.js +1 -0
  35. package/src/client/dist/spa/assets/QTabPanels-DV1b1MQb.js +1 -0
  36. package/src/client/dist/spa/assets/QToggle-B0HvuNEg.js +1 -0
  37. package/src/client/dist/spa/assets/QTooltip-kLXuUa_m.js +1 -0
  38. package/src/client/dist/spa/assets/SearchPage-DWglAeQv.css +1 -0
  39. package/src/client/dist/spa/assets/SearchPage-ZDAo7WgD.js +1 -0
  40. package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +1 -0
  41. package/src/client/dist/spa/assets/SettingsPage-D89evCuo.js +1 -0
  42. package/src/client/dist/spa/assets/TouchPan-CVMnGs0y.js +1 -0
  43. package/src/client/dist/spa/assets/{WorkspacePage-Ck8G2Lhl.css → WorkspacePage-CWRMLYs-.css} +1 -1
  44. package/src/client/dist/spa/assets/WorkspacePage-Ds5Dqxas.js +4 -0
  45. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Cxt1D8wE.js +1 -0
  46. package/src/client/dist/spa/assets/{cssMode-TmSd3FtN.js → cssMode-C9wGTDAD.js} +1 -1
  47. package/src/client/dist/spa/assets/{editor.api-BYoDPXci.js → editor.api-K7V5sl05.js} +1 -1
  48. package/src/client/dist/spa/assets/{editor.main-D2_WTVNi.js → editor.main-vZ6V2hrP.js} +3 -3
  49. package/src/client/dist/spa/assets/focus-manager-DYbz9jFW.js +1 -0
  50. package/src/client/dist/spa/assets/formatters-BzaS4w0I.js +1 -0
  51. package/src/client/dist/spa/assets/{freemarker2-BK0cJTj4.js → freemarker2-CRk6pTND.js} +1 -1
  52. package/src/client/dist/spa/assets/{handlebars-BT6xKkcN.js → handlebars-Cs3bFomb.js} +1 -1
  53. package/src/client/dist/spa/assets/{html-G6AG34m0.js → html-BT4-1gwt.js} +1 -1
  54. package/src/client/dist/spa/assets/{htmlMode-Cal36mmb.js → htmlMode-DZ9LYDVG.js} +1 -1
  55. package/src/client/dist/spa/assets/i18n-C0RbMxeL.js +1 -0
  56. package/src/client/dist/spa/assets/i18n-CkN9X6lQ.js +1 -0
  57. package/src/client/dist/spa/assets/index-Br4eMfSu.js +5 -0
  58. package/src/client/dist/spa/assets/{javascript-B-6wB4JQ.js → javascript-C0nTLIDg.js} +1 -1
  59. package/src/client/dist/spa/assets/{jsonMode-CT4pwl6t.js → jsonMode-LIrD4Pxq.js} +1 -1
  60. package/src/client/dist/spa/assets/{liquid-BneTrBrq.js → liquid-BbaUnvHA.js} +1 -1
  61. package/src/client/dist/spa/assets/{marked.esm-C01v3JRg.js → marked.esm-gIBce057.js} +1 -1
  62. package/src/client/dist/spa/assets/{mdx-ByZYBp9K.js → mdx-B6iNIRi2.js} +1 -1
  63. package/src/client/dist/spa/assets/models-C3h6lSte.js +1 -0
  64. package/src/client/dist/spa/assets/{monaco.contribution-B2_1EuRF.js → monaco.contribution-CZ_PxrB6.js} +2 -2
  65. package/src/client/dist/spa/assets/pinia-C3JsrLkB.js +1 -0
  66. package/src/client/dist/spa/assets/private.use-form-BhKyDtO7.js +1 -0
  67. package/src/client/dist/spa/assets/{python-CqrolrIy.js → python-C0uk6BYc.js} +1 -1
  68. package/src/client/dist/spa/assets/rate-limit-labels-dCPVjS61.js +6 -0
  69. package/src/client/dist/spa/assets/{razor-CPg4dghH.js → razor-Bj__h6OQ.js} +1 -1
  70. package/src/client/dist/spa/assets/runtime-core.esm-bundler-C3IgBgY5.js +1 -0
  71. package/src/client/dist/spa/assets/scroll-CLibRGI-.js +1 -0
  72. package/src/client/dist/spa/assets/settings-B69lIVX0.js +1 -0
  73. package/src/client/dist/spa/assets/symbols-CAg-nBkV.js +1 -0
  74. package/src/client/dist/spa/assets/touch-ChrvzrnI.js +1 -0
  75. package/src/client/dist/spa/assets/{tsMode-CRHW8cKK.js → tsMode-ZSZcAFKU.js} +1 -1
  76. package/src/client/dist/spa/assets/{typescript-D2lNktM6.js → typescript-Bgw42jIH.js} +1 -1
  77. package/src/client/dist/spa/assets/use-dark-DnuCB6tC.js +1 -0
  78. package/src/client/dist/spa/assets/vue-i18n-eUDnMrPl.js +3 -0
  79. package/src/client/dist/spa/assets/{xml-C-qye9BU.js → xml-B9gVeW9V.js} +1 -1
  80. package/src/client/dist/spa/assets/{yaml-DujvOKOb.js → yaml-DUqEwwz-.js} +1 -1
  81. package/src/client/dist/spa/index.html +15 -7
  82. package/src/client/dist/spa/assets/ActivityFeed-BScp_ie9.js +0 -10
  83. package/src/client/dist/spa/assets/ClosePopup-BfANRQ7n.js +0 -1
  84. package/src/client/dist/spa/assets/CreatePage-Cam6EJCg.js +0 -2
  85. package/src/client/dist/spa/assets/CreatePage-Cy1V2bdx.css +0 -1
  86. package/src/client/dist/spa/assets/DiffViewer-DMa0FFUV.js +0 -2
  87. package/src/client/dist/spa/assets/MainLayout-1KXWlMYS.css +0 -1
  88. package/src/client/dist/spa/assets/QBadge-DENdn6Dv.js +0 -1
  89. package/src/client/dist/spa/assets/QExpansionItem-BBe4EJ8c.js +0 -1
  90. package/src/client/dist/spa/assets/QList-DJTvuGop.js +0 -1
  91. package/src/client/dist/spa/assets/QMenu-DnNUsPHb.js +0 -1
  92. package/src/client/dist/spa/assets/QSpinnerDots-DytntAbf.js +0 -1
  93. package/src/client/dist/spa/assets/SettingsPage-BedYmy7N.js +0 -1
  94. package/src/client/dist/spa/assets/SettingsPage-oWZ8sGFm.css +0 -1
  95. package/src/client/dist/spa/assets/TouchPan-D_gmnARo.js +0 -1
  96. package/src/client/dist/spa/assets/WorkspacePage-DcVj7f0F.js +0 -4
  97. package/src/client/dist/spa/assets/_plugin-vue_export-helper-DkL3SKZ8.js +0 -1
  98. package/src/client/dist/spa/assets/formatters-hnFdqDO2.js +0 -6
  99. package/src/client/dist/spa/assets/i18n-ByuXBoTq.js +0 -1
  100. package/src/client/dist/spa/assets/i18n-DyD-u9TB.js +0 -1
  101. package/src/client/dist/spa/assets/index-C9F8lzbI.js +0 -5
  102. package/src/client/dist/spa/assets/models-C4seT9_L.js +0 -1
  103. package/src/client/dist/spa/assets/private.use-form-BWfHn8G9.js +0 -1
  104. package/src/client/dist/spa/assets/scroll-CHtHNzvE.js +0 -1
  105. package/src/client/dist/spa/assets/settings-DNNmyYf-.js +0 -1
  106. package/src/client/dist/spa/assets/touch-5Jv2Ep3F.js +0 -1
  107. package/src/client/dist/spa/assets/use-checkbox-By4PLtaB.js +0 -1
  108. package/src/client/dist/spa/assets/vue-i18n-CFW7O-jl.js +0 -3
  109. /package/src/client/dist/spa/assets/{abap-Dvixoi-w.js → abap-CFuyUYKP.js} +0 -0
  110. /package/src/client/dist/spa/assets/{apex-DP7X7Z9Q.js → apex-Ctq_xcrv.js} +0 -0
  111. /package/src/client/dist/spa/assets/{azcli-BdzyZYsP.js → azcli-BBQSVn-C.js} +0 -0
  112. /package/src/client/dist/spa/assets/{bat-BskgK8bi.js → bat-DbnqAfvr.js} +0 -0
  113. /package/src/client/dist/spa/assets/{bicep-BB_erhjR.js → bicep-BtDlIXop.js} +0 -0
  114. /package/src/client/dist/spa/assets/{cameligo-PvLD8t4t.js → cameligo-BLeJgKTj.js} +0 -0
  115. /package/src/client/dist/spa/assets/{clojure-CjHVLzZR.js → clojure-aZUQIUKP.js} +0 -0
  116. /package/src/client/dist/spa/assets/{coffee-DCoMPIwW.js → coffee-Secadq9U.js} +0 -0
  117. /package/src/client/dist/spa/assets/{cpp-kftb9yup.js → cpp-JicRPTRv.js} +0 -0
  118. /package/src/client/dist/spa/assets/{csharp-CYKl40UV.js → csharp-C7NSOZyj.js} +0 -0
  119. /package/src/client/dist/spa/assets/{csp-d1_9kWqP.js → csp-CIje7830.js} +0 -0
  120. /package/src/client/dist/spa/assets/{css-CSa8r6i8.js → css-G0bm1q_M.js} +0 -0
  121. /package/src/client/dist/spa/assets/{cypher-FoBj7udD.js → cypher-CldD5D0u.js} +0 -0
  122. /package/src/client/dist/spa/assets/{dart-BoITdHw1.js → dart-DIK3l8YT.js} +0 -0
  123. /package/src/client/dist/spa/assets/{dockerfile-CRYieHSB.js → dockerfile-czxaGh2L.js} +0 -0
  124. /package/src/client/dist/spa/assets/{ecl-BisizQmF.js → ecl-BqdYhwmw.js} +0 -0
  125. /package/src/client/dist/spa/assets/{elixir-DMza7PS-.js → elixir-m52LePTW.js} +0 -0
  126. /package/src/client/dist/spa/assets/{flow9-DZyDUZ6t.js → flow9-B5QJ9GvZ.js} +0 -0
  127. /package/src/client/dist/spa/assets/{format-Ex4UmWdz.js → format-Cyg8IgRi.js} +0 -0
  128. /package/src/client/dist/spa/assets/{fsharp-CKv7fFew.js → fsharp-B15czHsH.js} +0 -0
  129. /package/src/client/dist/spa/assets/{go-DXZqPXQn.js → go-BkoQxDo1.js} +0 -0
  130. /package/src/client/dist/spa/assets/{graphql-DrGE7IlY.js → graphql-BnI6uRa_.js} +0 -0
  131. /package/src/client/dist/spa/assets/{hcl-DLEN_UIX.js → hcl-CAwwENT7.js} +0 -0
  132. /package/src/client/dist/spa/assets/{ini-mQpaJDpd.js → ini-BHM5zh1H.js} +0 -0
  133. /package/src/client/dist/spa/assets/{java-jjG-xxwG.js → java-B5i95QvQ.js} +0 -0
  134. /package/src/client/dist/spa/assets/{julia-C50bVRho.js → julia-DPDm885q.js} +0 -0
  135. /package/src/client/dist/spa/assets/{kotlin-DwU9Bxzl.js → kotlin-qoccd5BP.js} +0 -0
  136. /package/src/client/dist/spa/assets/{less-Duxayr-t.js → less-B6RU166D.js} +0 -0
  137. /package/src/client/dist/spa/assets/{lexon-D3Je_oKk.js → lexon-YfUeoL1V.js} +0 -0
  138. /package/src/client/dist/spa/assets/{lua-DqW2q2h7.js → lua-BIUI5y9b.js} +0 -0
  139. /package/src/client/dist/spa/assets/{m3-CO3sU7vD.js → m3-D5SAbSdU.js} +0 -0
  140. /package/src/client/dist/spa/assets/{markdown-CbqjBAOS.js → markdown-CVJLwHzJ.js} +0 -0
  141. /package/src/client/dist/spa/assets/{mips-Bj7AN96v.js → mips-R-FZ3zOR.js} +0 -0
  142. /package/src/client/dist/spa/assets/{msdax-aLgc6HYp.js → msdax-Blveyl9r.js} +0 -0
  143. /package/src/client/dist/spa/assets/{mysql-QJHu-LiC.js → mysql-D4mY1AFx.js} +0 -0
  144. /package/src/client/dist/spa/assets/{objective-c-BRcZj02O.js → objective-c-BmXrLr4h.js} +0 -0
  145. /package/src/client/dist/spa/assets/{pascal-j04gUgnY.js → pascal-yxckoyvV.js} +0 -0
  146. /package/src/client/dist/spa/assets/{pascaligo-6Lz1Pduc.js → pascaligo-Q5JCwXMI.js} +0 -0
  147. /package/src/client/dist/spa/assets/{perl-Ny9A9N4B.js → perl-BF1Rrs5h.js} +0 -0
  148. /package/src/client/dist/spa/assets/{pgsql-gEZ-9kd2.js → pgsql-CnYB97wm.js} +0 -0
  149. /package/src/client/dist/spa/assets/{php-B81GxKax.js → php-CdDfQfSg.js} +0 -0
  150. /package/src/client/dist/spa/assets/{pla-DS3E9Auu.js → pla-whj-d71F.js} +0 -0
  151. /package/src/client/dist/spa/assets/{postiats-CHuHziKk.js → postiats-ClfLr4I-.js} +0 -0
  152. /package/src/client/dist/spa/assets/{powerquery-DWZsw4AU.js → powerquery-iRaBhuuk.js} +0 -0
  153. /package/src/client/dist/spa/assets/{powershell-MAGzBvoK.js → powershell-DjiEt5xK.js} +0 -0
  154. /package/src/client/dist/spa/assets/{protobuf-CuqThPZN.js → protobuf-B6dcIEUr.js} +0 -0
  155. /package/src/client/dist/spa/assets/{pug-DpwILTue.js → pug-DtmHnjM9.js} +0 -0
  156. /package/src/client/dist/spa/assets/{qsharp-Clbrosr3.js → qsharp-CELCyd79.js} +0 -0
  157. /package/src/client/dist/spa/assets/{r-dNsdrNRn.js → r-ZpJXWV-o.js} +0 -0
  158. /package/src/client/dist/spa/assets/{redis-DRY-nYja.js → redis-BiHSNkAl.js} +0 -0
  159. /package/src/client/dist/spa/assets/{redshift-CGzDbZLe.js → redshift-DzuwYCHP.js} +0 -0
  160. /package/src/client/dist/spa/assets/{restructuredtext-DiI6OCjv.js → restructuredtext-YOT94bbS.js} +0 -0
  161. /package/src/client/dist/spa/assets/{ruby-DTTFtlqw.js → ruby-BfiHr6Uu.js} +0 -0
  162. /package/src/client/dist/spa/assets/{rust-Cv26V_Fa.js → rust-JZ-uOoYM.js} +0 -0
  163. /package/src/client/dist/spa/assets/{sb-WM3UPTLr.js → sb-CBglP1-t.js} +0 -0
  164. /package/src/client/dist/spa/assets/{scala-Brw-oZFc.js → scala-C9l41paw.js} +0 -0
  165. /package/src/client/dist/spa/assets/{scheme-B9_NJi2n.js → scheme-B-InQ6hy.js} +0 -0
  166. /package/src/client/dist/spa/assets/{scss-AJXcdTUI.js → scss-v6OmJRN9.js} +0 -0
  167. /package/src/client/dist/spa/assets/{shell-B8rXUKDl.js → shell-Dyp6iwB6.js} +0 -0
  168. /package/src/client/dist/spa/assets/{solidity-DBduMAba.js → solidity-D5epNWue.js} +0 -0
  169. /package/src/client/dist/spa/assets/{sophia-CLkSrv5f.js → sophia-Eva-79sB.js} +0 -0
  170. /package/src/client/dist/spa/assets/{sparql-8jfjvjhF.js → sparql-gvALLO1w.js} +0 -0
  171. /package/src/client/dist/spa/assets/{sql-DCI2HndY.js → sql-COdamZYI.js} +0 -0
  172. /package/src/client/dist/spa/assets/{st-YiBlEXuf.js → st-eMoImIwE.js} +0 -0
  173. /package/src/client/dist/spa/assets/{swift-BpRZ1CVX.js → swift-7R_T9RYH.js} +0 -0
  174. /package/src/client/dist/spa/assets/{systemverilog-2_XLvhYi.js → systemverilog-1pCEfaHU.js} +0 -0
  175. /package/src/client/dist/spa/assets/{tcl-6xehSJT3.js → tcl-B_KgnhfE.js} +0 -0
  176. /package/src/client/dist/spa/assets/{twig-B80I9c0D.js → twig-CFZUJxb9.js} +0 -0
  177. /package/src/client/dist/spa/assets/{typespec-BsAPJtGA.js → typespec-B1ZgHlud.js} +0 -0
  178. /package/src/client/dist/spa/assets/{vb-B6Q-99Bj.js → vb-DKdun5tL.js} +0 -0
  179. /package/src/client/dist/spa/assets/{wgsl-DPkCsCSS.js → wgsl-CzNaxTrn.js} +0 -0
@@ -64,6 +64,20 @@ export const migrations = [
64
64
  db.exec("ALTER TABLE workspaces ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'auto'");
65
65
  },
66
66
  },
67
+ {
68
+ version: 8,
69
+ name: 'add-workspace-favorited-at',
70
+ migrate: (db) => {
71
+ db.prepare('ALTER TABLE workspaces ADD COLUMN favorited_at TEXT').run();
72
+ },
73
+ },
74
+ {
75
+ version: 9,
76
+ name: 'add-workspace-tags',
77
+ migrate: (db) => {
78
+ db.prepare("ALTER TABLE workspaces ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'").run();
79
+ },
80
+ },
67
81
  ];
68
82
  /** Current schema version — always equals the highest migration version. */
69
83
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -16,6 +16,8 @@ export function initSchema(db) {
16
16
  dev_server_status TEXT NOT NULL DEFAULT 'stopped',
17
17
  has_unread INTEGER NOT NULL DEFAULT 0,
18
18
  archived_at TEXT,
19
+ favorited_at TEXT,
20
+ tags TEXT NOT NULL DEFAULT '[]',
19
21
  created_at TEXT NOT NULL,
20
22
  updated_at TEXT NOT NULL
21
23
  );
@@ -9,20 +9,23 @@ import { closeDb, getDb } from './db/index.js';
9
9
  import { runMigrations } from './db/migrations.js';
10
10
  import devServerRouter from './routes/dev-server.js';
11
11
  import gitRouter from './routes/git.js';
12
+ import healthRouter from './routes/health.js';
12
13
  import imagesRouter from './routes/images.js';
13
14
  import notionRouter from './routes/notion.js';
14
15
  import plansRouter from './routes/plans.js';
16
+ import searchRouter from './routes/search.js';
15
17
  import sentryRouter from './routes/sentry.js';
16
18
  import settingsRouter from './routes/settings.js';
17
19
  import templatesRouter from './routes/templates.js';
18
20
  import workspacesRouter from './routes/workspaces.js';
19
21
  import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent-manager.js';
22
+ import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
20
23
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
21
24
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
22
25
  import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
23
26
  import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
24
27
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
25
- import { getClientSpaPath, getKoboHome, getPackageVersion } from './utils/paths.js';
28
+ import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
26
29
  import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
27
30
  // Runtime prerequisite check — warn if the claude CLI is missing. Don't block
28
31
  // startup: the user may still want to configure settings or browse workspaces.
@@ -36,6 +39,17 @@ console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
36
39
  // Initialize DB + run migrations
37
40
  const db = getDb();
38
41
  runMigrations(db);
42
+ // Daily DB backup (best-effort, fire-and-forget — never blocks boot).
43
+ // Creates a WAL-safe snapshot alongside kobo.db if no backup exists in the
44
+ // last 24h, and rotates out older backups beyond the retention window.
45
+ void createDailyDbBackupIfNeeded(db, getDbPath()).then((r) => {
46
+ if (r.created) {
47
+ console.log(`[kobo] Daily DB backup: ${r.created}`);
48
+ if (r.deleted.length > 0) {
49
+ console.log(`[kobo] Rotated ${r.deleted.length} old DB backup(s)`);
50
+ }
51
+ }
52
+ });
39
53
  // Initialize process cleanup, agent watchdog, and PR watcher
40
54
  initProcessCleanup();
41
55
  startWatchdog();
@@ -54,6 +68,8 @@ app.route('/api/settings', settingsRouter);
54
68
  app.route('/api/dev-server', devServerRouter);
55
69
  app.route('/api/templates', templatesRouter);
56
70
  app.route('/api/workspaces', plansRouter);
71
+ app.route('/api/search', searchRouter);
72
+ app.route('/api/health', healthRouter);
57
73
  // Skills endpoint
58
74
  app.get('/api/skills', (c) => c.json(getAvailableSkills()));
59
75
  const PORT = parseInt(process.env.SERVER_PORT || process.env.PORT || '3000', 10);
@@ -11,7 +11,7 @@ app.get('/:workspaceId/status', (c) => {
11
11
  if (!workspace) {
12
12
  return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
13
13
  }
14
- const status = getStatus(workspace.projectPath, workspace.workingBranch);
14
+ const status = getStatus(workspace.projectPath, workspace.workingBranch, workspaceId);
15
15
  // If runtime detection returns unknown, use persisted status from DB
16
16
  if (status.status === 'unknown' && workspace.devServerStatus && workspace.devServerStatus !== 'stopped') {
17
17
  status.status = workspace.devServerStatus;
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Hono } from 'hono';
5
+ import { getDb } from '../db/index.js';
6
+ import { SCHEMA_VERSION } from '../db/migrations.js';
7
+ import { getGlobalSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
8
+ import { getDbPath, getKoboHome } from '../utils/paths.js';
9
+ const app = new Hono();
10
+ function checkClaudeCli() {
11
+ try {
12
+ const r = spawnSync('claude', ['--version'], { encoding: 'utf-8' });
13
+ if (r.error || r.status !== 0)
14
+ return { available: false, version: null };
15
+ return { available: true, version: (r.stdout ?? '').trim() || null };
16
+ }
17
+ catch {
18
+ return { available: false, version: null };
19
+ }
20
+ }
21
+ function isProcessAlive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ function safeFileSize(p) {
31
+ try {
32
+ return fs.statSync(p).size;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ // GET /api/health/report — detailed health diagnostics for the Health panel.
39
+ app.get('/report', (c) => {
40
+ const db = getDb();
41
+ const dbPath = getDbPath();
42
+ const home = getKoboHome();
43
+ // DB schema version
44
+ const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
45
+ const dbSchemaVersion = row?.v ?? 0;
46
+ // Workspaces + worktrees on-disk check
47
+ const workspaces = db
48
+ .prepare('SELECT id, name, project_path, working_branch, archived_at FROM workspaces')
49
+ .all();
50
+ const worktreesMissing = [];
51
+ for (const ws of workspaces) {
52
+ if (ws.archived_at)
53
+ continue;
54
+ const wtPath = path.join(ws.project_path, '.worktrees', ws.working_branch);
55
+ if (!fs.existsSync(wtPath)) {
56
+ worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
57
+ }
58
+ }
59
+ // Orphan agent sessions — marked running but PID no longer alive
60
+ const runningSessions = db
61
+ .prepare("SELECT pid FROM agent_sessions WHERE status = 'running' AND pid IS NOT NULL")
62
+ .all();
63
+ let orphaned = 0;
64
+ for (const s of runningSessions) {
65
+ if (s.pid && !isProcessAlive(s.pid))
66
+ orphaned++;
67
+ }
68
+ const globalSettings = getGlobalSettings();
69
+ const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
70
+ const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
71
+ const report = {
72
+ koboHome: home,
73
+ db: {
74
+ path: dbPath,
75
+ sizeBytes: safeFileSize(dbPath),
76
+ schemaVersion: dbSchemaVersion,
77
+ currentSchemaVersion: SCHEMA_VERSION,
78
+ },
79
+ settings: { schemaVersion: SETTINGS_SCHEMA_VERSION },
80
+ claudeCli: checkClaudeCli(),
81
+ workspaces: {
82
+ total: settingsRow.n,
83
+ archived: archivedRow.n,
84
+ worktreesMissing,
85
+ },
86
+ agentSessions: { orphaned },
87
+ integrations: {
88
+ notion: { configured: Boolean(globalSettings.notionMcpKey) },
89
+ sentry: { configured: Boolean(globalSettings.sentryMcpKey) },
90
+ editor: { configured: Boolean(globalSettings.editorCommand) },
91
+ },
92
+ };
93
+ return c.json(report);
94
+ });
95
+ export default app;
@@ -0,0 +1,33 @@
1
+ import { Hono } from 'hono';
2
+ import { searchEvents } from '../services/search-service.js';
3
+ const DEFAULT_LIMIT = 50;
4
+ const MAX_LIMIT = 200;
5
+ const app = new Hono();
6
+ // GET /api/search?q=...&limit=50&includeArchived=true
7
+ // Search readable text across ws_events (user messages + agent outputs),
8
+ // joined with workspaces. Returns up to `limit` snippets, most recent first.
9
+ app.get('/', (c) => {
10
+ const qRaw = c.req.query('q') ?? '';
11
+ const q = qRaw.trim();
12
+ if (!q) {
13
+ return c.json({ error: "Missing or empty 'q' query parameter" }, 400);
14
+ }
15
+ let limit = DEFAULT_LIMIT;
16
+ const limitRaw = c.req.query('limit');
17
+ if (limitRaw) {
18
+ const parsed = parseInt(limitRaw, 10);
19
+ if (Number.isFinite(parsed) && parsed > 0) {
20
+ limit = Math.min(parsed, MAX_LIMIT);
21
+ }
22
+ }
23
+ const includeArchived = c.req.query('includeArchived') === 'true';
24
+ try {
25
+ const results = searchEvents(q, { limit, includeArchived });
26
+ return c.json(results);
27
+ }
28
+ catch (err) {
29
+ const message = err instanceof Error ? err.message : String(err);
30
+ return c.json({ error: message }, 500);
31
+ }
32
+ });
33
+ export default app;
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import * as settingsService from '../services/settings-service.js';
3
+ import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
3
4
  /** Hono sub-router for global and per-project settings CRUD. */
4
5
  const app = new Hono();
5
6
  // GET /api/settings — return full settings
@@ -101,4 +102,33 @@ app.delete('/projects/:encodedPath', (c) => {
101
102
  return c.json({ error: message }, 500);
102
103
  }
103
104
  });
105
+ // GET /api/settings/export — download a JSON bundle of settings + templates (MCP keys stripped)
106
+ app.get('/export', (c) => {
107
+ try {
108
+ const bundle = settingsService.exportConfigBundle(listTemplates());
109
+ return c.json(bundle);
110
+ }
111
+ catch (err) {
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ return c.json({ error: message }, 500);
114
+ }
115
+ });
116
+ // POST /api/settings/import — replace settings + templates from an uploaded bundle
117
+ app.post('/import', async (c) => {
118
+ try {
119
+ const body = (await c.req.json());
120
+ // Validate settings first — throws on malformed payload before we touch disk.
121
+ settingsService.importConfigBundle(body);
122
+ if (body.templates !== undefined) {
123
+ // Accept missing templates (backward-compatible). Otherwise validate and replace.
124
+ replaceAllTemplates(body.templates);
125
+ }
126
+ return c.json({ ok: true });
127
+ }
128
+ catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ const isValidation = message.includes('Invalid bundle') || message.includes('Invalid template');
131
+ return c.json({ error: message }, isValidation ? 400 : 500);
132
+ }
133
+ });
104
134
  export default app;
@@ -756,6 +756,52 @@ app.get('/:id', (c) => {
756
756
  return c.json({ error: message }, 500);
757
757
  }
758
758
  });
759
+ // POST /api/workspaces/:id/favorite — mark workspace as favorite
760
+ app.post('/:id/favorite', (c) => {
761
+ const { id } = c.req.param();
762
+ try {
763
+ const ws = workspaceService.setFavorite(id);
764
+ return c.json(ws);
765
+ }
766
+ catch (err) {
767
+ const msg = err instanceof Error ? err.message : 'Unknown error';
768
+ const status = msg.includes('not found') ? 404 : 500;
769
+ return c.json({ error: msg }, status);
770
+ }
771
+ });
772
+ // DELETE /api/workspaces/:id/favorite — remove favorite from workspace
773
+ app.delete('/:id/favorite', (c) => {
774
+ const { id } = c.req.param();
775
+ try {
776
+ const ws = workspaceService.unsetFavorite(id);
777
+ return c.json(ws);
778
+ }
779
+ catch (err) {
780
+ const msg = err instanceof Error ? err.message : 'Unknown error';
781
+ const status = msg.includes('not found') ? 404 : 500;
782
+ return c.json({ error: msg }, status);
783
+ }
784
+ });
785
+ // PUT /api/workspaces/:id/tags — replace the workspace's tag list
786
+ app.put('/:id/tags', async (c) => {
787
+ const { id } = c.req.param();
788
+ try {
789
+ const body = await c.req.json();
790
+ if (!Array.isArray(body.tags)) {
791
+ return c.json({ error: 'tags must be an array of strings' }, 400);
792
+ }
793
+ if (body.tags.some((t) => typeof t !== 'string')) {
794
+ return c.json({ error: 'tags must contain only strings' }, 400);
795
+ }
796
+ const ws = workspaceService.setWorkspaceTags(id, body.tags);
797
+ return c.json(ws);
798
+ }
799
+ catch (err) {
800
+ const msg = err instanceof Error ? err.message : 'Unknown error';
801
+ const status = msg.includes('not found') ? 404 : 500;
802
+ return c.json({ error: msg }, status);
803
+ }
804
+ });
759
805
  // PATCH /api/workspaces/:id — update workspace fields (status, model, permissionMode, name)
760
806
  app.patch('/:id', async (c) => {
761
807
  try {
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const BACKUP_PREFIX = 'kobo.db.backup-';
4
+ const DEFAULT_KEEP = 7;
5
+ const DAILY_MS = 24 * 60 * 60 * 1000;
6
+ let backupSequence = 0;
7
+ /** Test-only: reset the in-process monotonic sequence to 0. */
8
+ export function _resetBackupSequenceForTests() {
9
+ backupSequence = 0;
10
+ }
11
+ function listBackups(dir) {
12
+ if (!fs.existsSync(dir))
13
+ return [];
14
+ return fs
15
+ .readdirSync(dir)
16
+ .filter((f) => f.startsWith(BACKUP_PREFIX))
17
+ .map((f) => {
18
+ const full = path.join(dir, f);
19
+ const stat = fs.statSync(full);
20
+ return { path: full, mtimeMs: stat.mtimeMs };
21
+ })
22
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
23
+ }
24
+ /**
25
+ * Create a WAL-safe snapshot of the Kōbō DB if no backup has been made in the
26
+ * last 24h. Rotates old backups, keeping only the `keepCount` most recent.
27
+ *
28
+ * - Location: same directory as `dbPath`, named `kobo.db.backup-<ISO>-<seq>`
29
+ * (mirrors the settings.json backup convention).
30
+ * - Uses better-sqlite3's online `.backup()` API, which is safe while the DB
31
+ * is open and under write load (WAL mode).
32
+ * - Best-effort: never throws. On failure, logs to console.error and returns
33
+ * `{ created: null, deleted: [] }` so the boot path is never blocked.
34
+ */
35
+ export async function createDailyDbBackupIfNeeded(db, dbPath, keepCount = DEFAULT_KEEP, nowMs = Date.now()) {
36
+ const result = { created: null, deleted: [] };
37
+ const dir = path.dirname(dbPath);
38
+ try {
39
+ const existing = listBackups(dir);
40
+ const latestMtime = existing[0]?.mtimeMs ?? 0;
41
+ if (latestMtime && nowMs - latestMtime < DAILY_MS) {
42
+ return result;
43
+ }
44
+ const stamp = new Date(nowMs).toISOString().replace(/[:.]/g, '-');
45
+ backupSequence += 1;
46
+ const backupPath = path.join(dir, `${BACKUP_PREFIX}${stamp}-${backupSequence}`);
47
+ await db.backup(backupPath);
48
+ result.created = backupPath;
49
+ const all = listBackups(dir);
50
+ if (all.length > keepCount) {
51
+ for (const entry of all.slice(keepCount)) {
52
+ try {
53
+ fs.unlinkSync(entry.path);
54
+ result.deleted.push(entry.path);
55
+ }
56
+ catch (err) {
57
+ console.error(`[kobo] Failed to delete old DB backup ${entry.path}:`, err);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ catch (err) {
63
+ console.error('[kobo] DB daily backup failed:', err);
64
+ }
65
+ return result;
66
+ }
@@ -16,6 +16,10 @@ function cleanEnv() {
16
16
  // ── State ──────────────────────────────────────────────────────────────────────
17
17
  /** workspaceId -> spawned dev-server process */
18
18
  const trackedProcesses = new Map();
19
+ /** Test-only: clear the tracked-processes map between tests. */
20
+ export function _resetTrackedProcessesForTests() {
21
+ trackedProcesses.clear();
22
+ }
19
23
  // ── Pure helpers ───────────────────────────────────────────────────────────────
20
24
  /**
21
25
  * Sanitize a branch name for use as a Docker instance name.
@@ -94,8 +98,14 @@ export function listRunningContainers() {
94
98
  // ── Status ─────────────────────────────────────────────────────────────────────
95
99
  /**
96
100
  * Get the dev-server status for a given project + branch.
101
+ *
102
+ * When `workspaceId` is provided and a start process for that workspace is
103
+ * still running (e.g. `docker compose up -d` is pulling/building images), the
104
+ * status is reported as `'starting'` even if no matching container is visible
105
+ * in `docker ps` yet. This prevents the UI from flashing to `'stopped'` during
106
+ * long build phases.
97
107
  */
98
- export function getStatus(projectPath, workingBranch) {
108
+ export function getStatus(projectPath, workingBranch, workspaceId) {
99
109
  const config = resolveInstance(projectPath, workingBranch);
100
110
  if (!config) {
101
111
  return {
@@ -119,6 +129,19 @@ export function getStatus(projectPath, workingBranch) {
119
129
  containers: matching,
120
130
  };
121
131
  }
132
+ // No matching container yet — but is a start process still in flight?
133
+ // This covers the long `docker compose up -d` build/pull phase where the
134
+ // CLI hasn't exited yet and containers haven't appeared in `docker ps`.
135
+ if (workspaceId && trackedProcesses.has(workspaceId)) {
136
+ return {
137
+ status: 'starting',
138
+ instanceName: config.instanceName,
139
+ projectName: config.projectName,
140
+ httpPort: config.httpPort,
141
+ url: '',
142
+ containers: [],
143
+ };
144
+ }
122
145
  return {
123
146
  status: 'stopped',
124
147
  instanceName: config.instanceName,
@@ -0,0 +1,102 @@
1
+ import { getDb } from '../db/index.js';
2
+ /** Event types that carry user-authored or assistant-authored readable text. */
3
+ const SEARCHABLE_TYPES = ['user:message', 'agent:output'];
4
+ const SNIPPET_CONTEXT = 100; // chars on each side of the match
5
+ /**
6
+ * Extract the readable text content of a ws_events payload, or `null` when
7
+ * the event carries no natural-language content (system events, rate-limit
8
+ * pings, etc.).
9
+ */
10
+ function extractReadableText(type, payload) {
11
+ if (!payload || typeof payload !== 'object')
12
+ return null;
13
+ const p = payload;
14
+ if (type === 'user:message') {
15
+ return typeof p.content === 'string' ? p.content : null;
16
+ }
17
+ if (type === 'agent:output') {
18
+ // Claude Code streams `{type: 'assistant', message: {content: [{type: 'text', text: '...'}, ...]}}`
19
+ const msg = p.message;
20
+ if (msg && Array.isArray(msg.content)) {
21
+ const parts = [];
22
+ for (const block of msg.content) {
23
+ if (block && typeof block === 'object') {
24
+ const b = block;
25
+ if (b.type === 'text' && typeof b.text === 'string')
26
+ parts.push(b.text);
27
+ }
28
+ }
29
+ return parts.length > 0 ? parts.join('\n') : null;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ function buildSnippet(text, matchIndex, query) {
35
+ const start = Math.max(0, matchIndex - SNIPPET_CONTEXT);
36
+ const end = Math.min(text.length, matchIndex + query.length + SNIPPET_CONTEXT);
37
+ const prefix = start > 0 ? '…' : '';
38
+ const suffix = end < text.length ? '…' : '';
39
+ return `${prefix}${text.slice(start, end).trim()}${suffix}`;
40
+ }
41
+ /**
42
+ * Full-text-ish search across `ws_events.payload` joined with `workspaces`.
43
+ *
44
+ * - Trimmed empty queries return `[]` without hitting the database.
45
+ * - SQLite does the first filter via `LIKE '%q%'` on the raw payload JSON.
46
+ * Results are then post-filtered in JS against the **readable** text
47
+ * (extracted from the JSON) to avoid false positives like matches inside
48
+ * field names or schema strings.
49
+ * - Snippets are 100 chars of context on either side of the first match.
50
+ */
51
+ export function searchEvents(query, options = {}) {
52
+ const trimmed = query.trim();
53
+ if (trimmed.length === 0)
54
+ return [];
55
+ const { limit = 50, includeArchived = false } = options;
56
+ const db = getDb();
57
+ const typePlaceholders = SEARCHABLE_TYPES.map(() => '?').join(', ');
58
+ const archiveFilter = includeArchived ? '' : 'AND w.archived_at IS NULL';
59
+ const likePattern = `%${trimmed}%`;
60
+ // Over-fetch a bit so post-filter rejects don't shrink us below `limit` on
61
+ // realistic datasets, without scanning the entire table.
62
+ const dbLimit = Math.max(limit * 3, 100);
63
+ const rows = db
64
+ .prepare(`SELECT e.workspace_id, e.type, e.created_at, e.payload,
65
+ w.name AS workspace_name, w.archived_at
66
+ FROM ws_events e
67
+ JOIN workspaces w ON e.workspace_id = w.id
68
+ WHERE e.type IN (${typePlaceholders})
69
+ AND e.payload LIKE ?
70
+ ${archiveFilter}
71
+ ORDER BY e.created_at DESC
72
+ LIMIT ?`)
73
+ .all(...SEARCHABLE_TYPES, likePattern, dbLimit);
74
+ const needle = trimmed.toLowerCase();
75
+ const results = [];
76
+ for (const row of rows) {
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(row.payload);
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ const text = extractReadableText(row.type, parsed);
85
+ if (!text)
86
+ continue;
87
+ const idx = text.toLowerCase().indexOf(needle);
88
+ if (idx < 0)
89
+ continue;
90
+ results.push({
91
+ workspaceId: row.workspace_id,
92
+ workspaceName: row.workspace_name,
93
+ archived: row.archived_at !== null,
94
+ type: row.type,
95
+ timestamp: row.created_at,
96
+ snippet: buildSnippet(text, idx, trimmed),
97
+ });
98
+ if (results.length >= limit)
99
+ break;
100
+ }
101
+ return results;
102
+ }
@@ -53,6 +53,8 @@ Please:
53
53
  3. Post a comment on the PR summarizing what was done and any follow-up items
54
54
  4. Do NOT add a "Generated with Claude Code" footer or any AI attribution to the PR description
55
55
  `;
56
+ /** Default workspace tags seeded on fresh install and on settings upgrade. */
57
+ export const DEFAULT_WORKSPACE_TAGS = ['bug', 'feature', 'refactor', 'docs', 'wip', 'urgent', 'blocked'];
56
58
  const settingsMigrations = [
57
59
  {
58
60
  version: 1,
@@ -136,6 +138,14 @@ const settingsMigrations = [
136
138
  global.sentryMcpKey = '';
137
139
  },
138
140
  },
141
+ {
142
+ version: 8,
143
+ name: 'add-workspace-tags',
144
+ migrate({ global }) {
145
+ if (!Array.isArray(global.tags))
146
+ global.tags = [...DEFAULT_WORKSPACE_TAGS];
147
+ },
148
+ },
139
149
  ];
140
150
  /** Current settings schema version — always equals the highest migration version. */
141
151
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -175,6 +185,7 @@ function defaultSettings() {
175
185
  defaultPermissionMode: 'plan',
176
186
  notionMcpKey: '',
177
187
  sentryMcpKey: '',
188
+ tags: [...DEFAULT_WORKSPACE_TAGS],
178
189
  },
179
190
  projects: [],
180
191
  };
@@ -189,8 +200,6 @@ function defaultProjectSettings(projectPath) {
189
200
  prPromptTemplate: '',
190
201
  gitConventions: '',
191
202
  setupScript: '',
192
- notionInProgressStatus: '',
193
- notionStatusProperty: '',
194
203
  devServer: {
195
204
  startCommand: '',
196
205
  stopCommand: '',
@@ -281,6 +290,58 @@ function writeSettings(settings, options) {
281
290
  export function getSettings() {
282
291
  return readSettings();
283
292
  }
293
+ /** Keys stripped from exports — secrets that should stay on the machine. */
294
+ const SECRET_GLOBAL_KEYS = ['notionMcpKey', 'sentryMcpKey'];
295
+ /** Build an export bundle with settings + templates. MCP keys are stripped. */
296
+ export function exportConfigBundle(templates) {
297
+ const settings = readSettings();
298
+ const sanitizedGlobal = { ...settings.global };
299
+ for (const key of SECRET_GLOBAL_KEYS) {
300
+ sanitizedGlobal[key] = '';
301
+ }
302
+ return {
303
+ bundleVersion: 1,
304
+ exportedAt: new Date().toISOString(),
305
+ settings: { ...settings, global: sanitizedGlobal },
306
+ templates,
307
+ };
308
+ }
309
+ /** Replace the settings file with an imported bundle. MCP keys in the current settings are preserved. */
310
+ export function importConfigBundle(bundle) {
311
+ if (!bundle || typeof bundle !== 'object') {
312
+ throw new Error('Invalid bundle: payload must be an object');
313
+ }
314
+ if (bundle.bundleVersion !== 1) {
315
+ throw new Error('Invalid bundle: expected bundleVersion = 1');
316
+ }
317
+ const incoming = bundle.settings;
318
+ if (!incoming || typeof incoming !== 'object') {
319
+ throw new Error('Invalid bundle: missing or malformed settings');
320
+ }
321
+ const incomingSettings = incoming;
322
+ if (!incomingSettings.global ||
323
+ typeof incomingSettings.global !== 'object' ||
324
+ Array.isArray(incomingSettings.global)) {
325
+ throw new Error('Invalid bundle: settings.global must be an object');
326
+ }
327
+ if (!Array.isArray(incomingSettings.projects)) {
328
+ throw new Error('Invalid bundle: settings.projects must be an array');
329
+ }
330
+ for (let i = 0; i < incomingSettings.projects.length; i++) {
331
+ const p = incomingSettings.projects[i];
332
+ if (!p || typeof p !== 'object' || Array.isArray(p)) {
333
+ throw new Error(`Invalid bundle: settings.projects[${i}] must be an object`);
334
+ }
335
+ }
336
+ const current = readSettings();
337
+ // Run the incoming through the migration pipeline in case an older version is imported.
338
+ const migrated = runSettingsMigrations(incoming);
339
+ // Preserve existing MCP keys — the export stripped them and we don't want to clobber a local config.
340
+ for (const key of SECRET_GLOBAL_KEYS) {
341
+ migrated.global[key] = current.global[key];
342
+ }
343
+ writeSettings(migrated, { backup: true });
344
+ }
284
345
  /** Return only the global settings section. */
285
346
  export function getGlobalSettings() {
286
347
  return readSettings().global;
@@ -315,8 +376,8 @@ export function getEffectiveSettings(projectPath) {
315
376
  sourceBranch: project.defaultSourceBranch,
316
377
  devServer: project.devServer,
317
378
  setupScript: project.setupScript || '',
318
- notionStatusProperty: project.notionStatusProperty || settings.global.notionStatusProperty,
319
- notionInProgressStatus: project.notionInProgressStatus || settings.global.notionInProgressStatus,
379
+ notionStatusProperty: settings.global.notionStatusProperty,
380
+ notionInProgressStatus: settings.global.notionInProgressStatus,
320
381
  };
321
382
  }
322
383
  /** Merge partial updates into global settings and persist. */
@@ -335,8 +396,16 @@ export function updateGlobalSettings(data) {
335
396
  'defaultPermissionMode',
336
397
  'notionMcpKey',
337
398
  'sentryMcpKey',
399
+ 'tags',
338
400
  ];
339
401
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
402
+ if (filtered.tags !== undefined) {
403
+ filtered.tags = Array.isArray(filtered.tags)
404
+ ? Array.from(new Set(filtered.tags
405
+ .map((t) => (typeof t === 'string' ? t.trim() : ''))
406
+ .filter((t) => t.length > 0 && t.length <= 50)))
407
+ : settings.global.tags;
408
+ }
340
409
  settings.global = { ...settings.global, ...filtered };
341
410
  writeSettings(settings, { backup: true });
342
411
  return settings.global;
@@ -351,8 +420,6 @@ export function upsertProject(projectPath, data) {
351
420
  'prPromptTemplate',
352
421
  'gitConventions',
353
422
  'setupScript',
354
- 'notionStatusProperty',
355
- 'notionInProgressStatus',
356
423
  'devServer',
357
424
  ];
358
425
  const allowedDevServerKeys = ['startCommand', 'stopCommand'];