@loicngr/kobo 1.2.0 → 1.4.0

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 (180) hide show
  1. package/AGENTS.md +14 -0
  2. package/dist/mcp-server/kobo-tasks-server.js +2 -0
  3. package/dist/server/db/index.js +1 -0
  4. package/dist/server/db/migrations.js +72 -10
  5. package/dist/server/db/schema.js +5 -0
  6. package/dist/server/index.js +63 -9
  7. package/dist/server/routes/workspaces.js +208 -52
  8. package/dist/server/services/agent-manager.js +101 -9
  9. package/dist/server/services/notion-service.js +6 -3
  10. package/dist/server/services/pr-watcher-service.js +82 -0
  11. package/dist/server/services/settings-service.js +41 -22
  12. package/dist/server/services/websocket-service.js +41 -4
  13. package/dist/server/services/workspace-service.js +25 -2
  14. package/dist/server/utils/git-ops.js +200 -4
  15. package/dist/server/utils/paths.js +13 -0
  16. package/dist/server/utils/process-tracker.js +0 -4
  17. package/package.json +4 -3
  18. package/src/client/dist/spa/assets/ActivityFeed-Dxuw_8et.js +60 -0
  19. package/src/client/dist/spa/assets/ActivityFeed-OvgJQL4-.css +1 -0
  20. package/src/client/dist/spa/assets/CreatePage-CTFi3DpD.js +2 -0
  21. package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +1 -0
  22. package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +1 -0
  23. package/src/client/dist/spa/assets/DiffViewer-DV9gt8DT.js +2 -0
  24. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
  25. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
  26. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
  27. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
  28. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
  29. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
  30. package/src/client/dist/spa/assets/MainLayout-BxqZy-kp.js +2 -0
  31. package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +1 -0
  32. package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +1 -0
  33. package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +1 -0
  34. package/src/client/dist/spa/assets/QExpansionItem-sghN-B7_.js +1 -0
  35. package/src/client/dist/spa/assets/QPage-DL4rY7LD.js +1 -0
  36. package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +1 -0
  37. package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +1 -0
  38. package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +1 -0
  39. package/src/client/dist/spa/assets/SettingsPage-50Nqrcsk.js +1 -0
  40. package/src/client/dist/spa/assets/SettingsPage-DV5avRbc.css +1 -0
  41. package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +1 -0
  42. package/src/client/dist/spa/assets/WorkspacePage-L46GJjcy.js +2 -0
  43. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +1 -0
  44. package/src/client/dist/spa/assets/abap-Co3wj02O.js +1 -0
  45. package/src/client/dist/spa/assets/apex-CUKwGs62.js +1 -0
  46. package/src/client/dist/spa/assets/azcli-DMImymmY.js +1 -0
  47. package/src/client/dist/spa/assets/bat--P_y70-E.js +1 -0
  48. package/src/client/dist/spa/assets/bicep-C3w6oSfK.js +2 -0
  49. package/src/client/dist/spa/assets/cameligo-D9NSR4Rj.js +1 -0
  50. package/src/client/dist/spa/assets/clojure-BMcQme0t.js +1 -0
  51. package/src/client/dist/spa/assets/codicon-CgENjH2v.ttf +0 -0
  52. package/src/client/dist/spa/assets/coffee-BbMZaWx7.js +1 -0
  53. package/src/client/dist/spa/assets/cpp-CbrtEGgw.js +1 -0
  54. package/src/client/dist/spa/assets/csharp-Bc0fjUxA.js +1 -0
  55. package/src/client/dist/spa/assets/csp-DmbXuMT0.js +1 -0
  56. package/src/client/dist/spa/assets/css-gdwCt5by.js +3 -0
  57. package/src/client/dist/spa/assets/css.worker-D1piIYC4.js +102 -0
  58. package/src/client/dist/spa/assets/cssMode-DO8hqIpD.js +4 -0
  59. package/src/client/dist/spa/assets/cypher-ocmmfoQr.js +1 -0
  60. package/src/client/dist/spa/assets/dart-DbZ5eklb.js +1 -0
  61. package/src/client/dist/spa/assets/dockerfile-BLaMayDc.js +1 -0
  62. package/src/client/dist/spa/assets/ecl-LxXpHirr.js +1 -0
  63. package/src/client/dist/spa/assets/editor-COGk2gAX.css +1 -0
  64. package/src/client/dist/spa/assets/editor-CS3NEPi9.css +1 -0
  65. package/src/client/dist/spa/assets/editor.api-BZP41lht.js +818 -0
  66. package/src/client/dist/spa/assets/editor.main-BOjf9Jyl.js +53 -0
  67. package/src/client/dist/spa/assets/editor.worker-CJ9iTmkr.js +26 -0
  68. package/src/client/dist/spa/assets/elixir-C_geKt5o.js +1 -0
  69. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
  70. package/src/client/dist/spa/assets/flow9-DE2fI2ca.js +1 -0
  71. package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +1 -0
  72. package/src/client/dist/spa/assets/freemarker2-QAd0phKD.js +3 -0
  73. package/src/client/dist/spa/assets/fsharp-CJD6fImD.js +1 -0
  74. package/src/client/dist/spa/assets/go-jUCqQ7bD.js +1 -0
  75. package/src/client/dist/spa/assets/graphql-rw7g9h7D.js +1 -0
  76. package/src/client/dist/spa/assets/handlebars-D40ZA-yu.js +1 -0
  77. package/src/client/dist/spa/assets/hcl-BKX27Mn7.js +1 -0
  78. package/src/client/dist/spa/assets/html-Bzo97Bk0.js +1 -0
  79. package/src/client/dist/spa/assets/html.worker-C4q4XMPn.js +509 -0
  80. package/src/client/dist/spa/assets/htmlMode-7HShfg96.js +4 -0
  81. package/src/client/dist/spa/assets/i18n-BiMAFoN_.js +1 -0
  82. package/src/client/dist/spa/assets/index-CaOiQq0z.js +5 -0
  83. package/src/client/dist/spa/assets/{index-BThMCiY7.css → index-eX_lKHSg.css} +1 -1
  84. package/src/client/dist/spa/assets/ini-CrXjga2H.js +1 -0
  85. package/src/client/dist/spa/assets/java-D4jksGBb.js +1 -0
  86. package/src/client/dist/spa/assets/javascript-DpFlF6yx.js +1 -0
  87. package/src/client/dist/spa/assets/json.worker-C9p7xCYk.js +65 -0
  88. package/src/client/dist/spa/assets/jsonMode-DxEb1VXU.js +10 -0
  89. package/src/client/dist/spa/assets/julia-CbWxfkeS.js +1 -0
  90. package/src/client/dist/spa/assets/kotlin-B26Yx80V.js +1 -0
  91. package/src/client/dist/spa/assets/less-DFzn-zC9.js +2 -0
  92. package/src/client/dist/spa/assets/lexon-C-w-W8Yv.js +1 -0
  93. package/src/client/dist/spa/assets/liquid-IpMvWkVS.js +1 -0
  94. package/src/client/dist/spa/assets/lua-CHuE_HoG.js +1 -0
  95. package/src/client/dist/spa/assets/m3-DEFZN2qS.js +1 -0
  96. package/src/client/dist/spa/assets/markdown-Cbt4TlFt.js +1 -0
  97. package/src/client/dist/spa/assets/mdx-BM5S9XtA.js +1 -0
  98. package/src/client/dist/spa/assets/mips-C6m4XECw.js +1 -0
  99. package/src/client/dist/spa/assets/monaco.contribution-Cpcgk43V.js +2 -0
  100. package/src/client/dist/spa/assets/msdax-un0CFb_S.js +1 -0
  101. package/src/client/dist/spa/assets/mysql-CuAPeiOV.js +1 -0
  102. package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +1 -0
  103. package/src/client/dist/spa/assets/objective-c-DLVMdxAC.js +1 -0
  104. package/src/client/dist/spa/assets/pascal-BGCThuPY.js +1 -0
  105. package/src/client/dist/spa/assets/pascaligo-DfxSVpdo.js +1 -0
  106. package/src/client/dist/spa/assets/perl-BOE6y94t.js +1 -0
  107. package/src/client/dist/spa/assets/pgsql-Dn7JkY4F.js +1 -0
  108. package/src/client/dist/spa/assets/php-r1gD0KyT.js +1 -0
  109. package/src/client/dist/spa/assets/pla-CgXknhb0.js +1 -0
  110. package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +1 -0
  111. package/src/client/dist/spa/assets/postiats-CsIEtnRB.js +1 -0
  112. package/src/client/dist/spa/assets/powerquery-yNJCmC_6.js +1 -0
  113. package/src/client/dist/spa/assets/powershell-CQcz1SqH.js +1 -0
  114. package/src/client/dist/spa/assets/protobuf-BmC34uvO.js +2 -0
  115. package/src/client/dist/spa/assets/pug-C20znvWM.js +1 -0
  116. package/src/client/dist/spa/assets/python-CBiKH2mZ.js +1 -0
  117. package/src/client/dist/spa/assets/qsharp-B7bnARMS.js +1 -0
  118. package/src/client/dist/spa/assets/r-ClvcLdqC.js +1 -0
  119. package/src/client/dist/spa/assets/razor-BV3hIY51.js +1 -0
  120. package/src/client/dist/spa/assets/redis-DCyda7_S.js +1 -0
  121. package/src/client/dist/spa/assets/redshift-BtWDr4pb.js +1 -0
  122. package/src/client/dist/spa/assets/restructuredtext-CLcnlkhl.js +1 -0
  123. package/src/client/dist/spa/assets/ruby-DY0SOSSZ.js +1 -0
  124. package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +1 -0
  125. package/src/client/dist/spa/assets/rust-JQd-fJZI.js +1 -0
  126. package/src/client/dist/spa/assets/sb-BV2j8yFF.js +1 -0
  127. package/src/client/dist/spa/assets/scala-DwbnREDs.js +1 -0
  128. package/src/client/dist/spa/assets/scheme-CrtA-vei.js +1 -0
  129. package/src/client/dist/spa/assets/scss-VxQz3zmI.js +3 -0
  130. package/src/client/dist/spa/assets/shell-CP9faqFI.js +1 -0
  131. package/src/client/dist/spa/assets/solidity-9IIb0b89.js +1 -0
  132. package/src/client/dist/spa/assets/sophia-D2LQU2AD.js +1 -0
  133. package/src/client/dist/spa/assets/sparql-DONCa5dy.js +1 -0
  134. package/src/client/dist/spa/assets/sql-DaAAHGEt.js +1 -0
  135. package/src/client/dist/spa/assets/st-CRY2V-j3.js +1 -0
  136. package/src/client/dist/spa/assets/swift-BlKbfloF.js +1 -0
  137. package/src/client/dist/spa/assets/systemverilog-B_h9Q_T_.js +1 -0
  138. package/src/client/dist/spa/assets/tcl-C4wN3A6M.js +1 -0
  139. package/src/client/dist/spa/assets/ts.worker-Cj3zTgVE.js +51353 -0
  140. package/src/client/dist/spa/assets/tsMode-DUqyritq.js +11 -0
  141. package/src/client/dist/spa/assets/twig-DDdaBLC9.js +1 -0
  142. package/src/client/dist/spa/assets/typescript-BvZDZzaz.js +1 -0
  143. package/src/client/dist/spa/assets/typespec-Dc1ipt8A.js +1 -0
  144. package/src/client/dist/spa/assets/use-checkbox-Dwcwf6Nj.js +1 -0
  145. package/src/client/dist/spa/assets/use-quasar-DMvrrord.js +1 -0
  146. package/src/client/dist/spa/assets/vb-C4BXIvrh.js +1 -0
  147. package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +3 -0
  148. package/src/client/dist/spa/assets/wgsl-XVg3Pi-r.js +298 -0
  149. package/src/client/dist/spa/assets/xml-BgsHEniP.js +1 -0
  150. package/src/client/dist/spa/assets/yaml-C-Mr6Xov.js +1 -0
  151. package/src/client/dist/spa/index.html +5 -3
  152. package/src/mcp-server/kobo-tasks-server.ts +2 -0
  153. package/src/client/dist/spa/assets/ActivityFeed-CPfYmybV.js +0 -60
  154. package/src/client/dist/spa/assets/ActivityFeed-DBljh9rq.css +0 -1
  155. package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +0 -1
  156. package/src/client/dist/spa/assets/CreatePage-C_c3Gr0F.js +0 -2
  157. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
  158. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
  159. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
  160. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
  161. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
  162. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
  163. package/src/client/dist/spa/assets/MainLayout-BMxEROm4.css +0 -1
  164. package/src/client/dist/spa/assets/MainLayout-QtbVbbnd.js +0 -1
  165. package/src/client/dist/spa/assets/QBadge-CNojh9Rl.js +0 -1
  166. package/src/client/dist/spa/assets/QDialog-DgR7t6Vf.js +0 -1
  167. package/src/client/dist/spa/assets/QExpansionItem-VVjlYOIT.js +0 -1
  168. package/src/client/dist/spa/assets/QPage-DX4g-Dpe.js +0 -1
  169. package/src/client/dist/spa/assets/QSpinnerDots-DeCf9Lr-.js +0 -1
  170. package/src/client/dist/spa/assets/QTooltip-DKYJ8kVW.js +0 -1
  171. package/src/client/dist/spa/assets/SettingsPage-DjWKsLC-.js +0 -1
  172. package/src/client/dist/spa/assets/SettingsPage-Yv31Z9aG.css +0 -1
  173. package/src/client/dist/spa/assets/WorkspacePage-DkM58caD.css +0 -1
  174. package/src/client/dist/spa/assets/WorkspacePage-EAh91w9s.js +0 -2
  175. package/src/client/dist/spa/assets/_plugin-vue_export-helper-C6NdfBK4.js +0 -1
  176. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  177. package/src/client/dist/spa/assets/index-C4WDJfjD.js +0 -5
  178. package/src/client/dist/spa/assets/nodes-irfhA8FK.js +0 -1
  179. package/src/client/dist/spa/assets/use-checkbox-BS9cbwg_.js +0 -1
  180. package/src/client/dist/spa/assets/use-quasar-CH0pSHUf.js +0 -1
@@ -0,0 +1,82 @@
1
+ import { getPrStatusAsync } from '../utils/git-ops.js';
2
+ import { emitEphemeral } from './websocket-service.js';
3
+ import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
4
+ // ── PR Watcher ────────────────────────────────────────────────────────────────
5
+ // Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
6
+ // automatically archive the corresponding workspace.
7
+ //
8
+ // Only archives on a STATE TRANSITION from OPEN → CLOSED/MERGED.
9
+ // If a PR is already closed/merged when first seen (e.g. after unarchive),
10
+ // it is recorded but NOT acted upon — prevents re-archiving manually
11
+ // unarchived workspaces.
12
+ const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
13
+ let timer = null;
14
+ let checking = false;
15
+ /** Tracks the last known PR state per workspace to detect transitions. */
16
+ const lastKnownState = new Map();
17
+ async function checkPrStatuses() {
18
+ const workspaces = listWorkspaces(false); // non-archived only
19
+ // Clean up entries for workspaces that no longer exist
20
+ for (const id of lastKnownState.keys()) {
21
+ if (!workspaces.some((ws) => ws.id === id)) {
22
+ lastKnownState.delete(id);
23
+ }
24
+ }
25
+ for (const ws of workspaces) {
26
+ // Only check workspaces that are not actively running an agent
27
+ if (['extracting', 'brainstorming', 'executing'].includes(ws.status))
28
+ continue;
29
+ try {
30
+ const pr = await getPrStatusAsync(ws.projectPath, ws.workingBranch);
31
+ if (!pr)
32
+ continue;
33
+ const prev = lastKnownState.get(ws.id);
34
+ lastKnownState.set(ws.id, pr.state);
35
+ // Only archive on a transition FROM OPEN — not on first sight of CLOSED/MERGED
36
+ if (prev === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
37
+ console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
38
+ archiveWorkspace(ws.id);
39
+ lastKnownState.delete(ws.id);
40
+ emitEphemeral(ws.id, 'workspace:archived', {
41
+ reason: `PR ${pr.state.toLowerCase()}`,
42
+ prUrl: pr.url,
43
+ });
44
+ }
45
+ }
46
+ catch (err) {
47
+ console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
48
+ }
49
+ }
50
+ }
51
+ function scheduleNext() {
52
+ timer = setTimeout(async () => {
53
+ if (checking) {
54
+ // Previous run still in progress — skip and reschedule
55
+ scheduleNext();
56
+ return;
57
+ }
58
+ checking = true;
59
+ try {
60
+ await checkPrStatuses();
61
+ }
62
+ catch (err) {
63
+ console.error('[pr-watcher] Unexpected error in checkPrStatuses:', err);
64
+ }
65
+ finally {
66
+ checking = false;
67
+ scheduleNext();
68
+ }
69
+ }, POLL_INTERVAL_MS);
70
+ timer.unref?.();
71
+ }
72
+ export function startPrWatcher() {
73
+ if (timer)
74
+ return;
75
+ scheduleNext();
76
+ }
77
+ export function stopPrWatcher() {
78
+ if (timer) {
79
+ clearTimeout(timer);
80
+ timer = null;
81
+ }
82
+ }
@@ -50,13 +50,36 @@ Please:
50
50
  1. Review the PR description on GitHub and improve it if needed (add a proper summary, screenshots if relevant, a test plan)
51
51
  2. Verify that all acceptance criteria are checked
52
52
  3. Post a comment on the PR summarizing what was done and any follow-up items
53
+ 4. Do NOT add a "Generated with Claude Code" footer or any AI attribution to the PR description
53
54
  `;
54
- /**
55
- * Bump when adding/removing/renaming fields in Settings that require a migration.
56
- * Each bump must come with a corresponding entry in `runSettingsMigrations()`.
57
- * Append-only — never renumber shipped versions.
58
- */
59
- export const SETTINGS_SCHEMA_VERSION = 1;
55
+ const settingsMigrations = [
56
+ {
57
+ version: 1,
58
+ name: 'add-git-conventions',
59
+ migrate: ({ global, projects }) => {
60
+ if (typeof global.gitConventions !== 'string')
61
+ global.gitConventions = '';
62
+ for (const p of projects) {
63
+ if (typeof p.gitConventions !== 'string')
64
+ p.gitConventions = '';
65
+ }
66
+ },
67
+ },
68
+ {
69
+ version: 2,
70
+ name: 'add-dangerously-skip-permissions',
71
+ migrate: ({ global, projects }) => {
72
+ if (typeof global.dangerouslySkipPermissions !== 'boolean')
73
+ global.dangerouslySkipPermissions = true;
74
+ for (const p of projects) {
75
+ if (typeof p.dangerouslySkipPermissions !== 'boolean')
76
+ p.dangerouslySkipPermissions = true;
77
+ }
78
+ },
79
+ },
80
+ ];
81
+ /** Current settings schema version — always equals the highest migration version. */
82
+ export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
60
83
  let settingsFilePath = getSettingsPath();
61
84
  /** Override the settings file path (used by tests). */
62
85
  export function _setSettingsPath(p) {
@@ -67,6 +90,7 @@ function defaultSettings() {
67
90
  schemaVersion: SETTINGS_SCHEMA_VERSION,
68
91
  global: {
69
92
  defaultModel: 'auto',
93
+ dangerouslySkipPermissions: true,
70
94
  prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
71
95
  gitConventions: DEFAULT_GIT_CONVENTIONS,
72
96
  },
@@ -79,6 +103,7 @@ function defaultProjectSettings(projectPath) {
79
103
  displayName: '',
80
104
  defaultSourceBranch: '',
81
105
  defaultModel: '',
106
+ dangerouslySkipPermissions: true,
82
107
  prPromptTemplate: '',
83
108
  gitConventions: '',
84
109
  devServer: {
@@ -92,12 +117,10 @@ function pickKnownKeys(data, allowedKeys) {
92
117
  }
93
118
  /**
94
119
  * Apply migrations sequentially to bring an older settings object up to
95
- * SETTINGS_SCHEMA_VERSION. Each migration is append-only — never edit or
96
- * reorder shipped migrations. The returned object carries the bumped
97
- * schemaVersion; callers should persist it back to disk.
120
+ * SETTINGS_SCHEMA_VERSION. Append-only — never edit or reorder shipped entries.
121
+ * The returned object carries the bumped schemaVersion; callers persist it.
98
122
  */
99
123
  export function runSettingsMigrations(raw) {
100
- // Ensure a baseline shape so we can safely read .global and .projects
101
124
  const current = raw;
102
125
  if (!current.global || typeof current.global !== 'object') {
103
126
  current.global = {};
@@ -105,20 +128,13 @@ export function runSettingsMigrations(raw) {
105
128
  if (!Array.isArray(current.projects)) {
106
129
  current.projects = [];
107
130
  }
108
- // Detect legacy (pre-versioned) settings as v0
109
131
  let version = typeof current.schemaVersion === 'number' ? current.schemaVersion : 0;
110
- // ── v0 v1: ensure gitConventions field exists on global and every project
111
- if (version < 1) {
112
- if (typeof current.global.gitConventions !== 'string') {
113
- current.global.gitConventions = '';
114
- }
115
- for (const p of current.projects) {
116
- if (typeof p.gitConventions !== 'string')
117
- p.gitConventions = '';
132
+ for (const m of settingsMigrations) {
133
+ if (version < m.version) {
134
+ m.migrate({ global: current.global, projects: current.projects });
135
+ version = m.version;
118
136
  }
119
- version = 1;
120
137
  }
121
- // Future migrations go here — increment SETTINGS_SCHEMA_VERSION in lockstep.
122
138
  current.schemaVersion = version;
123
139
  return current;
124
140
  }
@@ -177,6 +193,7 @@ export function getEffectiveSettings(projectPath) {
177
193
  if (!project) {
178
194
  return {
179
195
  model: settings.global.defaultModel,
196
+ dangerouslySkipPermissions: settings.global.dangerouslySkipPermissions,
180
197
  prPromptTemplate: settings.global.prPromptTemplate,
181
198
  gitConventions: settings.global.gitConventions,
182
199
  sourceBranch: '',
@@ -185,6 +202,7 @@ export function getEffectiveSettings(projectPath) {
185
202
  }
186
203
  return {
187
204
  model: project.defaultModel || settings.global.defaultModel,
205
+ dangerouslySkipPermissions: project.dangerouslySkipPermissions ?? settings.global.dangerouslySkipPermissions,
188
206
  prPromptTemplate: project.prPromptTemplate || settings.global.prPromptTemplate,
189
207
  gitConventions: project.gitConventions || settings.global.gitConventions,
190
208
  sourceBranch: project.defaultSourceBranch,
@@ -193,7 +211,7 @@ export function getEffectiveSettings(projectPath) {
193
211
  }
194
212
  export function updateGlobalSettings(data) {
195
213
  const settings = readSettings();
196
- const allowedGlobalKeys = ['defaultModel', 'prPromptTemplate', 'gitConventions'];
214
+ const allowedGlobalKeys = ['defaultModel', 'dangerouslySkipPermissions', 'prPromptTemplate', 'gitConventions'];
197
215
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
198
216
  settings.global = { ...settings.global, ...filtered };
199
217
  writeSettings(settings);
@@ -204,6 +222,7 @@ export function upsertProject(projectPath, data) {
204
222
  'displayName',
205
223
  'defaultSourceBranch',
206
224
  'defaultModel',
225
+ 'dangerouslySkipPermissions',
207
226
  'prPromptTemplate',
208
227
  'gitConventions',
209
228
  'devServer',
@@ -3,6 +3,9 @@ import { getDb } from '../db/index.js';
3
3
  // ── State ──────────────────────────────────────────────────────────────────────
4
4
  /** Maps each WS client to the set of workspaceIds they are subscribed to */
5
5
  const clients = new Map();
6
+ /** I6: Per-workspace emit counter for periodic cleanup */
7
+ const emitCounters = new Map();
8
+ const EMIT_CLEANUP_THRESHOLD = 2000;
6
9
  let messageHandler = null;
7
10
  export function setMessageHandler(handler) {
8
11
  messageHandler = handler;
@@ -67,10 +70,21 @@ export function handleConnection(ws) {
67
70
  ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
68
71
  }
69
72
  });
73
+ // Ping every 30s to keep the connection alive and detect stale clients
74
+ const pingInterval = setInterval(() => {
75
+ if (ws.readyState === ws.OPEN) {
76
+ ws.ping();
77
+ }
78
+ else {
79
+ clearInterval(pingInterval);
80
+ }
81
+ }, 30_000);
70
82
  ws.on('close', () => {
83
+ clearInterval(pingInterval);
71
84
  clients.delete(ws);
72
85
  });
73
86
  ws.on('error', () => {
87
+ clearInterval(pingInterval);
74
88
  clients.delete(ws);
75
89
  });
76
90
  }
@@ -87,6 +101,15 @@ export function emit(workspaceId, type, payload, sessionId) {
87
101
  try {
88
102
  const db = getDb();
89
103
  db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
104
+ // I6: Periodic cleanup — increment counter and trigger cleanup when threshold is reached
105
+ const count = (emitCounters.get(workspaceId) ?? 0) + 1;
106
+ if (count >= EMIT_CLEANUP_THRESHOLD) {
107
+ cleanupOldEvents(workspaceId);
108
+ emitCounters.set(workspaceId, 0);
109
+ }
110
+ else {
111
+ emitCounters.set(workspaceId, count);
112
+ }
90
113
  }
91
114
  catch (err) {
92
115
  console.error(`[websocket-service] Failed to persist event (workspace=${workspaceId}, type=${type}):`, err);
@@ -148,16 +171,16 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
148
171
  .all(...resolvedIds, lastRow.rowid);
149
172
  }
150
173
  else {
151
- // lastEventId not found — send all events for subscribed workspaces
174
+ // I7: lastEventId not found — send events capped to avoid unbounded memory usage
152
175
  rows = db
153
- .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC`)
176
+ .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC LIMIT 10000`)
154
177
  .all(...resolvedIds);
155
178
  }
156
179
  }
157
180
  else {
158
- // No lastEventId — send all events
181
+ // No lastEventId — send all events (capped to avoid unbounded memory usage)
159
182
  rows = db
160
- .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC`)
183
+ .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC LIMIT 10000`)
161
184
  .all(...resolvedIds);
162
185
  }
163
186
  const events = rows.map((row) => {
@@ -178,6 +201,13 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
178
201
  };
179
202
  });
180
203
  ws.send(JSON.stringify({ type: 'sync:response', payload: { events } }));
204
+ // Trigger cleanup when the event count is high to prevent unbounded growth
205
+ const CLEANUP_THRESHOLD = 5000;
206
+ if (rows.length >= CLEANUP_THRESHOLD) {
207
+ for (const wid of resolvedIds) {
208
+ cleanupOldEvents(wid);
209
+ }
210
+ }
181
211
  }
182
212
  // ── Cleanup ────────────────────────────────────────────────────────────────────
183
213
  /**
@@ -210,3 +240,10 @@ export function getClientCount() {
210
240
  export function _getClients() {
211
241
  return clients;
212
242
  }
243
+ /**
244
+ * Get the internal emit counters map — exposed for testing only.
245
+ * @internal
246
+ */
247
+ export function _getEmitCounters() {
248
+ return emitCounters;
249
+ }
@@ -22,6 +22,7 @@ function mapWorkspace(row) {
22
22
  notionUrl: row.notion_url,
23
23
  notionPageId: row.notion_page_id,
24
24
  model: row.model,
25
+ permissionMode: (row.permission_mode ?? 'auto-accept'),
25
26
  devServerStatus: row.dev_server_status,
26
27
  archivedAt: row.archived_at,
27
28
  createdAt: row.created_at,
@@ -87,13 +88,30 @@ export function updateWorkspaceStatus(id, status) {
87
88
  export function updateWorkspaceName(id, name) {
88
89
  const db = getDb();
89
90
  const now = new Date().toISOString();
90
- db.prepare('UPDATE workspaces SET name = ?, updated_at = ? WHERE id = ?').run(name, now, id);
91
+ const result = db.prepare('UPDATE workspaces SET name = ?, updated_at = ? WHERE id = ?').run(name, now, id);
92
+ if (result.changes === 0) {
93
+ throw new Error(`Workspace '${id}' not found`);
94
+ }
91
95
  return getWorkspace(id);
92
96
  }
93
97
  export function updateWorkspaceModel(id, model) {
94
98
  const db = getDb();
95
99
  const now = new Date().toISOString();
96
- db.prepare('UPDATE workspaces SET model = ?, updated_at = ? WHERE id = ?').run(model, now, id);
100
+ const result = db.prepare('UPDATE workspaces SET model = ?, updated_at = ? WHERE id = ?').run(model, now, id);
101
+ if (result.changes === 0) {
102
+ throw new Error(`Workspace '${id}' not found`);
103
+ }
104
+ return getWorkspace(id);
105
+ }
106
+ export function updateWorkspacePermissionMode(id, permissionMode) {
107
+ const db = getDb();
108
+ const now = new Date().toISOString();
109
+ const result = db
110
+ .prepare('UPDATE workspaces SET permission_mode = ?, updated_at = ? WHERE id = ?')
111
+ .run(permissionMode, now, id);
112
+ if (result.changes === 0) {
113
+ throw new Error(`Workspace '${id}' not found`);
114
+ }
97
115
  return getWorkspace(id);
98
116
  }
99
117
  export function updateDevServerStatus(id, status) {
@@ -120,6 +138,11 @@ export function createTask(workspaceId, data) {
120
138
  const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
121
139
  return mapTask(row);
122
140
  }
141
+ export function getTask(taskId, workspaceId) {
142
+ const db = getDb();
143
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId);
144
+ return row ? mapTask(row) : null;
145
+ }
123
146
  export function listTasks(workspaceId) {
124
147
  const db = getDb();
125
148
  const rows = db
@@ -1,4 +1,8 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFile as execFileCb, execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const execFileAsync = promisify(execFileCb);
2
6
  function git(repoPath, args) {
3
7
  return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
4
8
  }
@@ -99,9 +103,26 @@ export function pushBranch(repoPath, branchName, remote = 'origin') {
99
103
  throw new Error(`Failed to push branch '${branchName}' to '${remote}': ${message}`);
100
104
  }
101
105
  }
106
+ /** Try a git command with `base`, falling back to `origin/base` if the local ref is missing. */
107
+ function resolveBase(repoPath, base) {
108
+ try {
109
+ git(repoPath, ['rev-parse', '--verify', base]);
110
+ return base;
111
+ }
112
+ catch {
113
+ try {
114
+ git(repoPath, ['rev-parse', '--verify', `origin/${base}`]);
115
+ return `origin/${base}`;
116
+ }
117
+ catch {
118
+ return base;
119
+ }
120
+ }
121
+ }
102
122
  export function getCommitCount(repoPath, base, head) {
103
123
  try {
104
- const output = git(repoPath, ['rev-list', '--count', `${base}..${head}`]);
124
+ const ref = resolveBase(repoPath, base);
125
+ const output = git(repoPath, ['rev-list', '--count', `${ref}..${head}`]);
105
126
  return parseInt(output, 10) || 0;
106
127
  }
107
128
  catch {
@@ -110,7 +131,8 @@ export function getCommitCount(repoPath, base, head) {
110
131
  }
111
132
  export function getStructuredDiffStatsBetween(repoPath, base, head) {
112
133
  try {
113
- const output = git(repoPath, ['diff', '--shortstat', `${base}...${head}`]);
134
+ const ref = resolveBase(repoPath, base);
135
+ const output = git(repoPath, ['diff', '--shortstat', `${ref}...${head}`]);
114
136
  return parseDiffShortstat(output);
115
137
  }
116
138
  catch {
@@ -119,7 +141,8 @@ export function getStructuredDiffStatsBetween(repoPath, base, head) {
119
141
  }
120
142
  export function getCommitsBetween(repoPath, base, head) {
121
143
  try {
122
- return git(repoPath, ['log', `${base}..${head}`, '--pretty=format:- %s (%h)', '--no-merges']);
144
+ const ref = resolveBase(repoPath, base);
145
+ return git(repoPath, ['log', `${ref}..${head}`, '--pretty=format:- %s (%h)', '--no-merges']);
123
146
  }
124
147
  catch {
125
148
  return '';
@@ -136,6 +159,135 @@ export function getPrUrl(repoPath, branchName) {
136
159
  return null;
137
160
  }
138
161
  }
162
+ export function getPrStatus(repoPath, branchName) {
163
+ try {
164
+ const raw = execFileSync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
165
+ cwd: repoPath,
166
+ encoding: 'utf-8',
167
+ }).trim();
168
+ if (!raw)
169
+ return null;
170
+ const parsed = JSON.parse(raw);
171
+ return { state: parsed.state, url: parsed.url };
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
177
+ /** List files changed between base and HEAD (committed), plus working tree changes. */
178
+ export function getChangedFiles(repoPath, base) {
179
+ const ref = resolveBase(repoPath, base);
180
+ const files = [];
181
+ const seen = new Set();
182
+ // Committed changes (base..HEAD)
183
+ try {
184
+ const output = git(repoPath, ['diff', '--name-status', `${ref}...HEAD`]);
185
+ for (const line of output.split('\n')) {
186
+ if (!line)
187
+ continue;
188
+ const [statusCode, ...pathParts] = line.split('\t');
189
+ const filePath = pathParts.join('\t').replace(/\/$/, '');
190
+ if (!filePath)
191
+ continue;
192
+ let status = 'modified';
193
+ if (statusCode?.startsWith('A'))
194
+ status = 'added';
195
+ else if (statusCode?.startsWith('D'))
196
+ status = 'deleted';
197
+ else if (statusCode?.startsWith('R'))
198
+ status = 'renamed';
199
+ files.push({ path: filePath, status });
200
+ seen.add(filePath);
201
+ }
202
+ }
203
+ catch {
204
+ // No commits yet
205
+ }
206
+ // Working tree changes (uncommitted)
207
+ try {
208
+ const output = git(repoPath, ['status', '--porcelain', '-uall']);
209
+ for (const line of output.split('\n')) {
210
+ if (!line)
211
+ continue;
212
+ const filePath = line.substring(3).replace(/\/$/, '');
213
+ if (!filePath || seen.has(filePath))
214
+ continue;
215
+ const x = line[0];
216
+ const y = line[1];
217
+ let status = 'modified';
218
+ if (x === '?' && y === '?')
219
+ status = 'added';
220
+ else if (x === 'A' || y === 'A')
221
+ status = 'added';
222
+ else if (x === 'D' || y === 'D')
223
+ status = 'deleted';
224
+ files.push({ path: filePath, status });
225
+ }
226
+ }
227
+ catch {
228
+ // Ignore
229
+ }
230
+ return files;
231
+ }
232
+ /** Get the original content of a file at a given ref. Returns null if the file didn't exist. */
233
+ export function getFileAtRef(repoPath, ref, filePath) {
234
+ const resolvedRef = resolveBase(repoPath, ref);
235
+ try {
236
+ return git(repoPath, ['show', `${resolvedRef}:${filePath}`]);
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
242
+ /** Get the current content of a file in the worktree. Returns null if the file doesn't exist. */
243
+ export function getFileContent(repoPath, filePath) {
244
+ try {
245
+ return readFileSync(join(repoPath, filePath), 'utf-8');
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ }
251
+ export function getWorkingTreeStatus(repoPath) {
252
+ try {
253
+ const output = git(repoPath, ['status', '--porcelain']);
254
+ let staged = 0;
255
+ let modified = 0;
256
+ let untracked = 0;
257
+ for (const line of output.split('\n')) {
258
+ if (!line)
259
+ continue;
260
+ const x = line[0]; // index status
261
+ const y = line[1]; // worktree status
262
+ if (x === '?' && y === '?') {
263
+ untracked++;
264
+ }
265
+ else {
266
+ if (x !== ' ' && x !== '?')
267
+ staged++;
268
+ if (y !== ' ' && y !== '?')
269
+ modified++;
270
+ }
271
+ }
272
+ return { staged, modified, untracked };
273
+ }
274
+ catch {
275
+ return { staged: 0, modified: 0, untracked: 0 };
276
+ }
277
+ }
278
+ /** Count commits ahead of upstream. Returns -1 if no upstream is set. */
279
+ export function getUnpushedCount(repoPath) {
280
+ try {
281
+ const output = execFileSync('git', ['rev-list', '@{u}..HEAD', '--count'], {
282
+ cwd: repoPath,
283
+ encoding: 'utf-8',
284
+ }).trim();
285
+ return parseInt(output, 10) || 0;
286
+ }
287
+ catch {
288
+ return -1; // no upstream
289
+ }
290
+ }
139
291
  export function getDiffStatsBetween(repoPath, base, head) {
140
292
  try {
141
293
  return git(repoPath, ['diff', '--shortstat', `${base}...${head}`]);
@@ -144,3 +296,47 @@ export function getDiffStatsBetween(repoPath, base, head) {
144
296
  return '';
145
297
  }
146
298
  }
299
+ // ── Async versions ───────────────────────────────────────────────────────────
300
+ // Non-blocking alternatives for hot paths (pr-watcher, route handlers).
301
+ // The sync versions above are kept for callers that haven't migrated yet.
302
+ export async function getPrUrlAsync(repoPath, branchName) {
303
+ try {
304
+ const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
305
+ cwd: repoPath,
306
+ encoding: 'utf-8',
307
+ });
308
+ return stdout.trim() || null;
309
+ }
310
+ catch {
311
+ return null;
312
+ }
313
+ }
314
+ export async function getPrStatusAsync(repoPath, branchName) {
315
+ try {
316
+ const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
317
+ cwd: repoPath,
318
+ encoding: 'utf-8',
319
+ });
320
+ const raw = stdout.trim();
321
+ if (!raw)
322
+ return null;
323
+ const parsed = JSON.parse(raw);
324
+ return { state: parsed.state, url: parsed.url };
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ }
330
+ /** Async version of getUnpushedCount. Returns -1 if no upstream is set. */
331
+ export async function getUnpushedCountAsync(repoPath) {
332
+ try {
333
+ const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], {
334
+ cwd: repoPath,
335
+ encoding: 'utf-8',
336
+ });
337
+ return parseInt(stdout.trim(), 10) || 0;
338
+ }
339
+ catch {
340
+ return -1; // no upstream
341
+ }
342
+ }
@@ -85,6 +85,19 @@ export function getCompiledMcpServerPath() {
85
85
  export function getMcpServerSourcePath() {
86
86
  return getPackageAssetPath('src', 'mcp-server', 'kobo-tasks-server.ts');
87
87
  }
88
+ /**
89
+ * Returns the version string from the root package.json. The result is cached
90
+ * after the first call so the file is only read once per process lifetime.
91
+ */
92
+ let _cachedVersion = null;
93
+ export function getPackageVersion() {
94
+ if (_cachedVersion)
95
+ return _cachedVersion;
96
+ const pkgPath = getPackageAssetPath('package.json');
97
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
98
+ _cachedVersion = pkg.version;
99
+ return _cachedVersion;
100
+ }
88
101
  /**
89
102
  * Absolute path to the built Quasar SPA (src/client/dist/spa). Returns null
90
103
  * if the SPA has not been built yet.
@@ -39,8 +39,4 @@ export function killAll() {
39
39
  }
40
40
  export function initProcessCleanup() {
41
41
  process.on('exit', killAll);
42
- process.on('SIGINT', () => {
43
- killAll();
44
- process.exit(0);
45
- });
46
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
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",
@@ -53,6 +53,7 @@
53
53
  "build:client": "cd src/client && npx quasar build",
54
54
  "build:server": "npx tsc && chmod +x dist/server/index.js",
55
55
  "build": "npm run build:client && npm run build:server",
56
+ "install-all": "npm install && cd src/client && npm install",
56
57
  "start": "node dist/server/index.js",
57
58
  "test": "vitest run --config vitest.config.ts --root .",
58
59
  "test:watch": "vitest --config vitest.config.ts --root .",
@@ -65,10 +66,10 @@
65
66
  "prepublishOnly": "npm run build"
66
67
  },
67
68
  "dependencies": {
68
- "@hono/node-server": "^1.19.12",
69
+ "@hono/node-server": "^1.19.13",
69
70
  "@modelcontextprotocol/sdk": "^1.29.0",
70
71
  "better-sqlite3": "^12.8.0",
71
- "hono": "^4.12.10",
72
+ "hono": "^4.12.12",
72
73
  "nanoid": "^5.1.7",
73
74
  "ws": "^8.20.0"
74
75
  },