@loicngr/kobo 1.3.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 (179) 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 +11 -0
  5. package/dist/server/db/schema.js +4 -0
  6. package/dist/server/index.js +58 -7
  7. package/dist/server/routes/workspaces.js +157 -38
  8. package/dist/server/services/agent-manager.js +24 -6
  9. package/dist/server/services/notion-service.js +6 -3
  10. package/dist/server/services/pr-watcher-service.js +27 -6
  11. package/dist/server/services/websocket-service.js +41 -4
  12. package/dist/server/services/workspace-service.js +19 -3
  13. package/dist/server/utils/git-ops.js +172 -4
  14. package/dist/server/utils/paths.js +13 -0
  15. package/dist/server/utils/process-tracker.js +0 -4
  16. package/package.json +4 -3
  17. package/src/client/dist/spa/assets/ActivityFeed-Dxuw_8et.js +60 -0
  18. package/src/client/dist/spa/assets/ActivityFeed-OvgJQL4-.css +1 -0
  19. package/src/client/dist/spa/assets/CreatePage-CTFi3DpD.js +2 -0
  20. package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +1 -0
  21. package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +1 -0
  22. package/src/client/dist/spa/assets/DiffViewer-DV9gt8DT.js +2 -0
  23. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
  24. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
  25. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
  26. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
  27. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
  28. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
  29. package/src/client/dist/spa/assets/MainLayout-BxqZy-kp.js +2 -0
  30. package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +1 -0
  31. package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +1 -0
  32. package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +1 -0
  33. package/src/client/dist/spa/assets/QExpansionItem-sghN-B7_.js +1 -0
  34. package/src/client/dist/spa/assets/QPage-DL4rY7LD.js +1 -0
  35. package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +1 -0
  36. package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +1 -0
  37. package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +1 -0
  38. package/src/client/dist/spa/assets/SettingsPage-50Nqrcsk.js +1 -0
  39. package/src/client/dist/spa/assets/SettingsPage-DV5avRbc.css +1 -0
  40. package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +1 -0
  41. package/src/client/dist/spa/assets/WorkspacePage-L46GJjcy.js +2 -0
  42. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +1 -0
  43. package/src/client/dist/spa/assets/abap-Co3wj02O.js +1 -0
  44. package/src/client/dist/spa/assets/apex-CUKwGs62.js +1 -0
  45. package/src/client/dist/spa/assets/azcli-DMImymmY.js +1 -0
  46. package/src/client/dist/spa/assets/bat--P_y70-E.js +1 -0
  47. package/src/client/dist/spa/assets/bicep-C3w6oSfK.js +2 -0
  48. package/src/client/dist/spa/assets/cameligo-D9NSR4Rj.js +1 -0
  49. package/src/client/dist/spa/assets/clojure-BMcQme0t.js +1 -0
  50. package/src/client/dist/spa/assets/codicon-CgENjH2v.ttf +0 -0
  51. package/src/client/dist/spa/assets/coffee-BbMZaWx7.js +1 -0
  52. package/src/client/dist/spa/assets/cpp-CbrtEGgw.js +1 -0
  53. package/src/client/dist/spa/assets/csharp-Bc0fjUxA.js +1 -0
  54. package/src/client/dist/spa/assets/csp-DmbXuMT0.js +1 -0
  55. package/src/client/dist/spa/assets/css-gdwCt5by.js +3 -0
  56. package/src/client/dist/spa/assets/css.worker-D1piIYC4.js +102 -0
  57. package/src/client/dist/spa/assets/cssMode-DO8hqIpD.js +4 -0
  58. package/src/client/dist/spa/assets/cypher-ocmmfoQr.js +1 -0
  59. package/src/client/dist/spa/assets/dart-DbZ5eklb.js +1 -0
  60. package/src/client/dist/spa/assets/dockerfile-BLaMayDc.js +1 -0
  61. package/src/client/dist/spa/assets/ecl-LxXpHirr.js +1 -0
  62. package/src/client/dist/spa/assets/editor-COGk2gAX.css +1 -0
  63. package/src/client/dist/spa/assets/editor-CS3NEPi9.css +1 -0
  64. package/src/client/dist/spa/assets/editor.api-BZP41lht.js +818 -0
  65. package/src/client/dist/spa/assets/editor.main-BOjf9Jyl.js +53 -0
  66. package/src/client/dist/spa/assets/editor.worker-CJ9iTmkr.js +26 -0
  67. package/src/client/dist/spa/assets/elixir-C_geKt5o.js +1 -0
  68. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
  69. package/src/client/dist/spa/assets/flow9-DE2fI2ca.js +1 -0
  70. package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +1 -0
  71. package/src/client/dist/spa/assets/freemarker2-QAd0phKD.js +3 -0
  72. package/src/client/dist/spa/assets/fsharp-CJD6fImD.js +1 -0
  73. package/src/client/dist/spa/assets/go-jUCqQ7bD.js +1 -0
  74. package/src/client/dist/spa/assets/graphql-rw7g9h7D.js +1 -0
  75. package/src/client/dist/spa/assets/handlebars-D40ZA-yu.js +1 -0
  76. package/src/client/dist/spa/assets/hcl-BKX27Mn7.js +1 -0
  77. package/src/client/dist/spa/assets/html-Bzo97Bk0.js +1 -0
  78. package/src/client/dist/spa/assets/html.worker-C4q4XMPn.js +509 -0
  79. package/src/client/dist/spa/assets/htmlMode-7HShfg96.js +4 -0
  80. package/src/client/dist/spa/assets/i18n-BiMAFoN_.js +1 -0
  81. package/src/client/dist/spa/assets/index-CaOiQq0z.js +5 -0
  82. package/src/client/dist/spa/assets/{index-BThMCiY7.css → index-eX_lKHSg.css} +1 -1
  83. package/src/client/dist/spa/assets/ini-CrXjga2H.js +1 -0
  84. package/src/client/dist/spa/assets/java-D4jksGBb.js +1 -0
  85. package/src/client/dist/spa/assets/javascript-DpFlF6yx.js +1 -0
  86. package/src/client/dist/spa/assets/json.worker-C9p7xCYk.js +65 -0
  87. package/src/client/dist/spa/assets/jsonMode-DxEb1VXU.js +10 -0
  88. package/src/client/dist/spa/assets/julia-CbWxfkeS.js +1 -0
  89. package/src/client/dist/spa/assets/kotlin-B26Yx80V.js +1 -0
  90. package/src/client/dist/spa/assets/less-DFzn-zC9.js +2 -0
  91. package/src/client/dist/spa/assets/lexon-C-w-W8Yv.js +1 -0
  92. package/src/client/dist/spa/assets/liquid-IpMvWkVS.js +1 -0
  93. package/src/client/dist/spa/assets/lua-CHuE_HoG.js +1 -0
  94. package/src/client/dist/spa/assets/m3-DEFZN2qS.js +1 -0
  95. package/src/client/dist/spa/assets/markdown-Cbt4TlFt.js +1 -0
  96. package/src/client/dist/spa/assets/mdx-BM5S9XtA.js +1 -0
  97. package/src/client/dist/spa/assets/mips-C6m4XECw.js +1 -0
  98. package/src/client/dist/spa/assets/monaco.contribution-Cpcgk43V.js +2 -0
  99. package/src/client/dist/spa/assets/msdax-un0CFb_S.js +1 -0
  100. package/src/client/dist/spa/assets/mysql-CuAPeiOV.js +1 -0
  101. package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +1 -0
  102. package/src/client/dist/spa/assets/objective-c-DLVMdxAC.js +1 -0
  103. package/src/client/dist/spa/assets/pascal-BGCThuPY.js +1 -0
  104. package/src/client/dist/spa/assets/pascaligo-DfxSVpdo.js +1 -0
  105. package/src/client/dist/spa/assets/perl-BOE6y94t.js +1 -0
  106. package/src/client/dist/spa/assets/pgsql-Dn7JkY4F.js +1 -0
  107. package/src/client/dist/spa/assets/php-r1gD0KyT.js +1 -0
  108. package/src/client/dist/spa/assets/pla-CgXknhb0.js +1 -0
  109. package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +1 -0
  110. package/src/client/dist/spa/assets/postiats-CsIEtnRB.js +1 -0
  111. package/src/client/dist/spa/assets/powerquery-yNJCmC_6.js +1 -0
  112. package/src/client/dist/spa/assets/powershell-CQcz1SqH.js +1 -0
  113. package/src/client/dist/spa/assets/protobuf-BmC34uvO.js +2 -0
  114. package/src/client/dist/spa/assets/pug-C20znvWM.js +1 -0
  115. package/src/client/dist/spa/assets/python-CBiKH2mZ.js +1 -0
  116. package/src/client/dist/spa/assets/qsharp-B7bnARMS.js +1 -0
  117. package/src/client/dist/spa/assets/r-ClvcLdqC.js +1 -0
  118. package/src/client/dist/spa/assets/razor-BV3hIY51.js +1 -0
  119. package/src/client/dist/spa/assets/redis-DCyda7_S.js +1 -0
  120. package/src/client/dist/spa/assets/redshift-BtWDr4pb.js +1 -0
  121. package/src/client/dist/spa/assets/restructuredtext-CLcnlkhl.js +1 -0
  122. package/src/client/dist/spa/assets/ruby-DY0SOSSZ.js +1 -0
  123. package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +1 -0
  124. package/src/client/dist/spa/assets/rust-JQd-fJZI.js +1 -0
  125. package/src/client/dist/spa/assets/sb-BV2j8yFF.js +1 -0
  126. package/src/client/dist/spa/assets/scala-DwbnREDs.js +1 -0
  127. package/src/client/dist/spa/assets/scheme-CrtA-vei.js +1 -0
  128. package/src/client/dist/spa/assets/scss-VxQz3zmI.js +3 -0
  129. package/src/client/dist/spa/assets/shell-CP9faqFI.js +1 -0
  130. package/src/client/dist/spa/assets/solidity-9IIb0b89.js +1 -0
  131. package/src/client/dist/spa/assets/sophia-D2LQU2AD.js +1 -0
  132. package/src/client/dist/spa/assets/sparql-DONCa5dy.js +1 -0
  133. package/src/client/dist/spa/assets/sql-DaAAHGEt.js +1 -0
  134. package/src/client/dist/spa/assets/st-CRY2V-j3.js +1 -0
  135. package/src/client/dist/spa/assets/swift-BlKbfloF.js +1 -0
  136. package/src/client/dist/spa/assets/systemverilog-B_h9Q_T_.js +1 -0
  137. package/src/client/dist/spa/assets/tcl-C4wN3A6M.js +1 -0
  138. package/src/client/dist/spa/assets/ts.worker-Cj3zTgVE.js +51353 -0
  139. package/src/client/dist/spa/assets/tsMode-DUqyritq.js +11 -0
  140. package/src/client/dist/spa/assets/twig-DDdaBLC9.js +1 -0
  141. package/src/client/dist/spa/assets/typescript-BvZDZzaz.js +1 -0
  142. package/src/client/dist/spa/assets/typespec-Dc1ipt8A.js +1 -0
  143. package/src/client/dist/spa/assets/use-checkbox-Dwcwf6Nj.js +1 -0
  144. package/src/client/dist/spa/assets/use-quasar-DMvrrord.js +1 -0
  145. package/src/client/dist/spa/assets/vb-C4BXIvrh.js +1 -0
  146. package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +3 -0
  147. package/src/client/dist/spa/assets/wgsl-XVg3Pi-r.js +298 -0
  148. package/src/client/dist/spa/assets/xml-BgsHEniP.js +1 -0
  149. package/src/client/dist/spa/assets/yaml-C-Mr6Xov.js +1 -0
  150. package/src/client/dist/spa/index.html +5 -3
  151. package/src/mcp-server/kobo-tasks-server.ts +2 -0
  152. package/src/client/dist/spa/assets/ActivityFeed-Bie-lcn7.js +0 -60
  153. package/src/client/dist/spa/assets/ActivityFeed-D88GOO2z.css +0 -1
  154. package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +0 -1
  155. package/src/client/dist/spa/assets/CreatePage-OC-fnNGP.js +0 -2
  156. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
  157. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
  158. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
  159. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
  160. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
  161. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
  162. package/src/client/dist/spa/assets/MainLayout-91cUoVYa.css +0 -1
  163. package/src/client/dist/spa/assets/MainLayout-BIQNJixM.js +0 -1
  164. package/src/client/dist/spa/assets/QBadge-DbE3eSf1.js +0 -1
  165. package/src/client/dist/spa/assets/QDialog-Cd_4PvgW.js +0 -1
  166. package/src/client/dist/spa/assets/QExpansionItem-pMQDDRMv.js +0 -1
  167. package/src/client/dist/spa/assets/QPage-lhV4XbI2.js +0 -1
  168. package/src/client/dist/spa/assets/QSpinnerDots-ByNZaBWw.js +0 -1
  169. package/src/client/dist/spa/assets/QTooltip-6GSFtFKP.js +0 -1
  170. package/src/client/dist/spa/assets/SettingsPage-BPH70mno.css +0 -1
  171. package/src/client/dist/spa/assets/SettingsPage-s2WJBreM.js +0 -1
  172. package/src/client/dist/spa/assets/WorkspacePage-Dhkuuhf8.css +0 -1
  173. package/src/client/dist/spa/assets/WorkspacePage-XT26aCJE.js +0 -2
  174. package/src/client/dist/spa/assets/_plugin-vue_export-helper-B6FaNy4R.js +0 -1
  175. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  176. package/src/client/dist/spa/assets/index-BoQWbZtE.js +0 -5
  177. package/src/client/dist/spa/assets/nodes-CXdiSdC2.js +0 -1
  178. package/src/client/dist/spa/assets/use-checkbox-Z9pfihkw.js +0 -1
  179. package/src/client/dist/spa/assets/use-quasar-CtCe3LQU.js +0 -1
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
+ import { getPackageVersion } from '../utils/paths.js';
3
4
  // Gherkin keywords (French and English)
4
5
  const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
5
6
  // C2: rpcIdCounter encapsulated in a closure to avoid module-level mutable state
@@ -53,10 +54,11 @@ export async function callMcpTool(mcpProcess, toolName, args) {
53
54
  return;
54
55
  }
55
56
  let buffer = '';
56
- // C1: 30s timeout
57
+ // C1: 30s timeout — I7: kill the MCP process on timeout to avoid zombie
57
58
  const timeout = setTimeout(() => {
58
59
  mcpProcess.stdout?.removeListener('data', onData);
59
60
  mcpProcess.stdout?.removeListener('error', onError);
61
+ mcpProcess.kill();
60
62
  reject(new Error(`callMcpTool('${toolName}') timed out after 30s`));
61
63
  }, 30_000);
62
64
  const onData = (chunk) => {
@@ -155,7 +157,7 @@ async function initializeMcp(mcpProcess) {
155
157
  params: {
156
158
  protocolVersion: '2024-11-05',
157
159
  capabilities: {},
158
- clientInfo: { name: 'kobo', version: '0.1.0' },
160
+ clientInfo: { name: 'kobo', version: getPackageVersion() },
159
161
  },
160
162
  });
161
163
  await new Promise((resolve, reject) => {
@@ -164,9 +166,10 @@ async function initializeMcp(mcpProcess) {
164
166
  return;
165
167
  }
166
168
  let buffer = '';
167
- // C1: 10s timeout for initialization
169
+ // C1: 10s timeout for initialization — I7: kill the MCP process on timeout
168
170
  const timeout = setTimeout(() => {
169
171
  mcpProcess.stdout?.removeListener('data', onData);
172
+ mcpProcess.kill();
170
173
  reject(new Error('initializeMcp timed out after 10s'));
171
174
  }, 10_000);
172
175
  const onData = (chunk) => {
@@ -1,4 +1,4 @@
1
- import { getPrStatus } from '../utils/git-ops.js';
1
+ import { getPrStatusAsync } from '../utils/git-ops.js';
2
2
  import { emitEphemeral } from './websocket-service.js';
3
3
  import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
4
4
  // ── PR Watcher ────────────────────────────────────────────────────────────────
@@ -11,9 +11,10 @@ import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
11
11
  // unarchived workspaces.
12
12
  const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
13
13
  let timer = null;
14
+ let checking = false;
14
15
  /** Tracks the last known PR state per workspace to detect transitions. */
15
16
  const lastKnownState = new Map();
16
- function checkPrStatuses() {
17
+ async function checkPrStatuses() {
17
18
  const workspaces = listWorkspaces(false); // non-archived only
18
19
  // Clean up entries for workspaces that no longer exist
19
20
  for (const id of lastKnownState.keys()) {
@@ -26,7 +27,7 @@ function checkPrStatuses() {
26
27
  if (['extracting', 'brainstorming', 'executing'].includes(ws.status))
27
28
  continue;
28
29
  try {
29
- const pr = getPrStatus(ws.projectPath, ws.workingBranch);
30
+ const pr = await getPrStatusAsync(ws.projectPath, ws.workingBranch);
30
31
  if (!pr)
31
32
  continue;
32
33
  const prev = lastKnownState.get(ws.id);
@@ -47,15 +48,35 @@ function checkPrStatuses() {
47
48
  }
48
49
  }
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
+ }
50
72
  export function startPrWatcher() {
51
73
  if (timer)
52
74
  return;
53
- timer = setInterval(checkPrStatuses, POLL_INTERVAL_MS);
54
- timer.unref?.();
75
+ scheduleNext();
55
76
  }
56
77
  export function stopPrWatcher() {
57
78
  if (timer) {
58
- clearInterval(timer);
79
+ clearTimeout(timer);
59
80
  timer = null;
60
81
  }
61
82
  }
@@ -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
+ }
@@ -88,19 +88,30 @@ export function updateWorkspaceStatus(id, status) {
88
88
  export function updateWorkspaceName(id, name) {
89
89
  const db = getDb();
90
90
  const now = new Date().toISOString();
91
- 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
+ }
92
95
  return getWorkspace(id);
93
96
  }
94
97
  export function updateWorkspaceModel(id, model) {
95
98
  const db = getDb();
96
99
  const now = new Date().toISOString();
97
- 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
+ }
98
104
  return getWorkspace(id);
99
105
  }
100
106
  export function updateWorkspacePermissionMode(id, permissionMode) {
101
107
  const db = getDb();
102
108
  const now = new Date().toISOString();
103
- db.prepare('UPDATE workspaces SET permission_mode = ?, updated_at = ? WHERE id = ?').run(permissionMode, now, id);
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
+ }
104
115
  return getWorkspace(id);
105
116
  }
106
117
  export function updateDevServerStatus(id, status) {
@@ -127,6 +138,11 @@ export function createTask(workspaceId, data) {
127
138
  const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
128
139
  return mapTask(row);
129
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
+ }
130
146
  export function listTasks(workspaceId) {
131
147
  const db = getDb();
132
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 '';
@@ -151,6 +174,107 @@ export function getPrStatus(repoPath, branchName) {
151
174
  return null;
152
175
  }
153
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
+ }
154
278
  /** Count commits ahead of upstream. Returns -1 if no upstream is set. */
155
279
  export function getUnpushedCount(repoPath) {
156
280
  try {
@@ -172,3 +296,47 @@ export function getDiffStatsBetween(repoPath, base, head) {
172
296
  return '';
173
297
  }
174
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.3.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
  },