@loicngr/kobo 1.11.8 → 1.11.10

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 +2 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +2 -2
  4. package/dist/server/db/migrations.js +155 -19
  5. package/dist/server/db/schema.js +93 -0
  6. package/dist/server/index.js +35 -26
  7. package/dist/server/routes/dev-server.js +10 -8
  8. package/dist/server/routes/documents.js +33 -3
  9. package/dist/server/routes/health.js +16 -15
  10. package/dist/server/routes/images.js +17 -2
  11. package/dist/server/routes/workspaces.js +64 -49
  12. package/dist/server/services/agent/engines/codex/engine.js +5 -1
  13. package/dist/server/services/agent/orchestrator.js +8 -1
  14. package/dist/server/services/auto-loop-service.js +1 -1
  15. package/dist/server/services/change-source-branch-service.js +43 -3
  16. package/dist/server/services/dev-server-service.js +55 -41
  17. package/dist/server/services/forge/bitbucket/provider.js +6 -11
  18. package/dist/server/services/forge/github/provider.js +10 -9
  19. package/dist/server/services/forge/gitlab/provider.js +11 -13
  20. package/dist/server/services/git-stats-service.js +8 -6
  21. package/dist/server/services/image-service.js +34 -5
  22. package/dist/server/services/notion-service.js +45 -5
  23. package/dist/server/services/pr-watcher-service.js +20 -9
  24. package/dist/server/services/search-service.js +53 -40
  25. package/dist/server/services/websocket-service.js +8 -3
  26. package/dist/server/services/workspace-service.js +81 -10
  27. package/dist/server/services/worktree-purge-service.js +1 -1
  28. package/dist/server/utils/git-ops.js +89 -22
  29. package/dist/server/utils/mcp-client.js +4 -0
  30. package/dist/server/utils/safe-path.js +14 -0
  31. package/dist/server/utils/script-runner.js +16 -1
  32. package/package.json +22 -22
  33. package/src/client/dist/pwa/assets/{ActivityFeed-D9EfbRqv.css → ActivityFeed-BrjeLlhi.css} +1 -1
  34. package/src/client/dist/pwa/assets/ActivityFeed-UEAexGjc.js +8 -0
  35. package/src/client/dist/pwa/assets/ChangelogPage-XSYwu9XA.js +1 -0
  36. package/src/client/dist/pwa/assets/{ClosePopup-_rbZbpfb.js → ClosePopup-dpcrW9DC.js} +1 -1
  37. package/src/client/dist/pwa/assets/CreatePage-BK__rjvR.js +2 -0
  38. package/src/client/dist/pwa/assets/CreatePage-QwtfYunr.css +1 -0
  39. package/src/client/dist/pwa/assets/{DiffViewer-CRrDdYBa.css → DiffViewer-CS3P9Ngi.css} +1 -1
  40. package/src/client/dist/pwa/assets/DiffViewer-DnFN6Sc_.js +8 -0
  41. package/src/client/dist/pwa/assets/HealthPage-DVrZfMI6.js +1 -0
  42. package/src/client/dist/pwa/assets/MainLayout-D_3nFaCG.js +19 -0
  43. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +1 -0
  44. package/src/client/dist/pwa/assets/QBadge-sMJZ_eZm.js +1 -0
  45. package/src/client/dist/pwa/assets/QBanner-DedhGMcj.js +1 -0
  46. package/src/client/dist/pwa/assets/QBtnToggle-BgfN19l8.js +1 -0
  47. package/src/client/dist/pwa/assets/QChip-EQGYShN0.js +1 -0
  48. package/src/client/dist/pwa/assets/QIcon--SvgE-nS.js +1 -0
  49. package/src/client/dist/pwa/assets/QList-Be_FbTA3.js +1 -0
  50. package/src/client/dist/pwa/assets/QPage-DuIsRmYu.js +1 -0
  51. package/src/client/dist/pwa/assets/QSelect-CtxxliRU.js +36 -0
  52. package/src/client/dist/pwa/assets/QSeparator-BdyRE-AM.js +1 -0
  53. package/src/client/dist/pwa/assets/QSpace-D9UEvM99.js +1 -0
  54. package/src/client/dist/pwa/assets/QSpinnerDots-B4RmFJUj.js +1 -0
  55. package/src/client/dist/pwa/assets/QTooltip-HEIFh5w9.js +1 -0
  56. package/src/client/dist/pwa/assets/SearchPage-1B5HOf90.js +1 -0
  57. package/src/client/dist/pwa/assets/SettingsPage-BXiWr2ba.css +1 -0
  58. package/src/client/dist/pwa/assets/SettingsPage-v8f4rrCt.js +16 -0
  59. package/src/client/dist/pwa/assets/TouchPan-lynWXY69.js +1 -0
  60. package/src/client/dist/pwa/assets/WorkspacePage-ChnyJ3xI.css +1 -0
  61. package/src/client/dist/pwa/assets/WorkspacePage-Vjzi8YnW.js +4 -0
  62. package/src/client/dist/pwa/assets/codicon-Brq4_Ui5.ttf +0 -0
  63. package/src/client/dist/pwa/assets/{css.worker-CBlhdqTa.js → css.worker-JKZQyld4.js} +38 -34
  64. package/src/client/dist/pwa/assets/cssMode-cg8iVI-i.js +1 -0
  65. package/src/client/dist/pwa/assets/documents-Dlx8yiSO.js +1 -0
  66. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +1 -0
  67. package/src/client/dist/pwa/assets/editor.api-CfaJRkrX.js +847 -0
  68. package/src/client/dist/pwa/assets/editor.worker-D-m8IBMa.js +30 -0
  69. package/src/client/dist/pwa/assets/expand-template-D3LZldj1.js +1 -0
  70. package/src/client/dist/pwa/assets/formatters-D0dU6VcA.js +1 -0
  71. package/src/client/dist/pwa/assets/{freemarker2-DczqHd5F.js → freemarker2-B_8OIClK.js} +1 -1
  72. package/src/client/dist/pwa/assets/handlebars-CaYi_uxU.js +1 -0
  73. package/src/client/dist/pwa/assets/html-BnCUDUl1.js +1 -0
  74. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +506 -0
  75. package/src/client/dist/pwa/assets/htmlMode-BZjKGnQF.js +1 -0
  76. package/src/client/dist/pwa/assets/i18n-B6SDs7Lm.js +1 -0
  77. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +1 -0
  78. package/src/client/dist/pwa/assets/index-CoOsPyyG.js +82 -0
  79. package/src/client/dist/pwa/assets/{javascript-Bs83z_W4.js → javascript-DkSfoeHU.js} +1 -1
  80. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +62 -0
  81. package/src/client/dist/pwa/assets/{jsonMode-Bc-8bzJi.js → jsonMode-yKXxfx1V.js} +3 -3
  82. package/src/client/dist/pwa/assets/{kobo-commands-B2kM0INN.js → kobo-commands-vn1IQxLn.js} +1 -1
  83. package/src/client/dist/pwa/assets/layout-CiQpugQB.js +1 -0
  84. package/src/client/dist/pwa/assets/liquid-Dlhysv3U.js +1 -0
  85. package/src/client/dist/pwa/assets/lspLanguageFeatures-CeYSQFUz.js +4 -0
  86. package/src/client/dist/pwa/assets/mdx-B8iME5NA.js +1 -0
  87. package/src/client/dist/pwa/assets/network-auth-j2Drut6q.js +1 -0
  88. package/src/client/dist/pwa/assets/pending-diff-open-B4RYz7G_.js +1 -0
  89. package/src/client/dist/pwa/assets/permissionModes-6jhegAm-.js +1 -0
  90. package/src/client/dist/pwa/assets/{project-color-DIFZ95_g.js → project-color-e3OdFi9X.js} +1 -1
  91. package/src/client/dist/pwa/assets/python-CbFbC0ZR.js +1 -0
  92. package/src/client/dist/pwa/assets/razor-7c2NCdFI.js +1 -0
  93. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DtSZKRQB.js +1 -0
  94. package/src/client/dist/pwa/assets/render-chat-markdown-Fer8kY38.js +67 -0
  95. package/src/client/dist/pwa/assets/runtime-dom.esm-bundler-DgevF6KV.js +4 -0
  96. package/src/client/dist/pwa/assets/{ts.worker-PmaSgaZk.js → ts.worker-C-oGa8JT.js} +199 -195
  97. package/src/client/dist/pwa/assets/{tsMode-DOsxzgZi.js → tsMode-BmShKjbx.js} +1 -1
  98. package/src/client/dist/pwa/assets/typescript-Dhu7Upbs.js +1 -0
  99. package/src/client/dist/pwa/assets/use-checkbox-SrkgiJho.js +1 -0
  100. package/src/client/dist/pwa/assets/use-onboarding-CTZuBZOi.css +1 -0
  101. package/src/client/dist/pwa/assets/use-onboarding-fe4sPCNf.js +2 -0
  102. package/src/client/dist/pwa/assets/use-quasar-CeHlTrGg.js +1 -0
  103. package/src/client/dist/pwa/assets/vs-B-xsceNQ.js +56 -0
  104. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +1 -0
  105. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +3 -0
  106. package/src/client/dist/pwa/assets/workers-zVMTXVoC.js +1 -0
  107. package/src/client/dist/pwa/assets/xml-D64wpCtR.js +1 -0
  108. package/src/client/dist/pwa/assets/yaml-CLSRKwfx.js +1 -0
  109. package/src/client/dist/pwa/index.html +8 -8
  110. package/src/client/dist/pwa/manifest.json +1 -1
  111. package/src/client/dist/pwa/sw.js +1 -1
  112. package/src/client/dist/pwa/assets/ActivityFeed-BkkyAw81.js +0 -8
  113. package/src/client/dist/pwa/assets/ChangelogPage-BlvhBx6o.js +0 -1
  114. package/src/client/dist/pwa/assets/CreatePage-B_JcmRfU.css +0 -1
  115. package/src/client/dist/pwa/assets/CreatePage-RSyoQwD7.js +0 -2
  116. package/src/client/dist/pwa/assets/DiffViewer-BSNxNex2.js +0 -8
  117. package/src/client/dist/pwa/assets/HealthPage-CUD1kipV.js +0 -1
  118. package/src/client/dist/pwa/assets/MainLayout-BCLgsn0C.js +0 -37
  119. package/src/client/dist/pwa/assets/MainLayout-DlS2wxkK.css +0 -1
  120. package/src/client/dist/pwa/assets/QBadge-DV5kn4mL.js +0 -1
  121. package/src/client/dist/pwa/assets/QBanner-CmfaeizG.js +0 -1
  122. package/src/client/dist/pwa/assets/QBtnGroup-C442-yBb.js +0 -1
  123. package/src/client/dist/pwa/assets/QBtnToggle-xg3LYW7D.js +0 -1
  124. package/src/client/dist/pwa/assets/QChip-DhUfnmbz.js +0 -1
  125. package/src/client/dist/pwa/assets/QIcon-C6E-oEjI.js +0 -1
  126. package/src/client/dist/pwa/assets/QInput-BF8x4RTv.js +0 -1
  127. package/src/client/dist/pwa/assets/QList-D5EKdFPn.js +0 -1
  128. package/src/client/dist/pwa/assets/QPage-D3rjwA1l.js +0 -1
  129. package/src/client/dist/pwa/assets/QSelect-BhyT0k2N.js +0 -36
  130. package/src/client/dist/pwa/assets/QSeparator-DWTCQxsa.js +0 -1
  131. package/src/client/dist/pwa/assets/QSpace-ezNrZkuj.js +0 -1
  132. package/src/client/dist/pwa/assets/QSpinnerDots-DIV68-cV.js +0 -1
  133. package/src/client/dist/pwa/assets/QTooltip-ybbFi3KK.js +0 -1
  134. package/src/client/dist/pwa/assets/SearchPage-Bb7TG3Vo.js +0 -1
  135. package/src/client/dist/pwa/assets/SettingsPage-C2qe4xKu.css +0 -1
  136. package/src/client/dist/pwa/assets/SettingsPage-CjPnnn60.js +0 -16
  137. package/src/client/dist/pwa/assets/ThinkingItem-B1RKyCIS.js +0 -1
  138. package/src/client/dist/pwa/assets/ThinkingItem-Ckk4K1O-.css +0 -1
  139. package/src/client/dist/pwa/assets/TouchPan-DzZPYIol.js +0 -1
  140. package/src/client/dist/pwa/assets/WorkspacePage-6Rgjarvf.js +0 -4
  141. package/src/client/dist/pwa/assets/WorkspacePage-Bgxg_Rua.css +0 -1
  142. package/src/client/dist/pwa/assets/build-path-tree-KRACVkD3.js +0 -1
  143. package/src/client/dist/pwa/assets/chunk-QTnfLwEv.js +0 -1
  144. package/src/client/dist/pwa/assets/codicon-ngg6Pgfi.ttf +0 -0
  145. package/src/client/dist/pwa/assets/cssMode-CQsB6mFO.js +0 -1
  146. package/src/client/dist/pwa/assets/documents-C7efBF5t.js +0 -1
  147. package/src/client/dist/pwa/assets/editor-BFebQ78e.css +0 -1
  148. package/src/client/dist/pwa/assets/editor.api2-C58pkxmw.js +0 -872
  149. package/src/client/dist/pwa/assets/editor.main-BhLqjdq4.js +0 -6
  150. package/src/client/dist/pwa/assets/editor.worker-Cu3tR8iJ.js +0 -26
  151. package/src/client/dist/pwa/assets/expand-template-BA-hhG5v.js +0 -1
  152. package/src/client/dist/pwa/assets/formatters-DugWNOEs.js +0 -1
  153. package/src/client/dist/pwa/assets/handlebars-DmA7twi8.js +0 -1
  154. package/src/client/dist/pwa/assets/html-DtZDrViU.js +0 -1
  155. package/src/client/dist/pwa/assets/html.worker-BKBrNna1.js +0 -502
  156. package/src/client/dist/pwa/assets/htmlMode-DO9_qUdT.js +0 -1
  157. package/src/client/dist/pwa/assets/i18n-CipNqHTm.js +0 -1
  158. package/src/client/dist/pwa/assets/index-BxspWSDs.js +0 -84
  159. package/src/client/dist/pwa/assets/index-CwYR3iHt.css +0 -1
  160. package/src/client/dist/pwa/assets/json.worker-BaLYt9Ss.js +0 -58
  161. package/src/client/dist/pwa/assets/layout-C6frIxKZ.js +0 -1
  162. package/src/client/dist/pwa/assets/liquid-Dnxe0NUB.js +0 -1
  163. package/src/client/dist/pwa/assets/lspLanguageFeatures-qDBRYovw.js +0 -4
  164. package/src/client/dist/pwa/assets/mdx-C1oke9bq.js +0 -1
  165. package/src/client/dist/pwa/assets/monaco.contribution-BbFzrpO3.js +0 -2
  166. package/src/client/dist/pwa/assets/network-auth-Bj1LS5R7.js +0 -1
  167. package/src/client/dist/pwa/assets/pending-diff-open-C3voSObg.js +0 -1
  168. package/src/client/dist/pwa/assets/permissionModes-DMuXGtZu.js +0 -1
  169. package/src/client/dist/pwa/assets/python-Dgq-bJSM.js +0 -1
  170. package/src/client/dist/pwa/assets/razor-Bms4CQGF.js +0 -1
  171. package/src/client/dist/pwa/assets/render-chat-markdown-BRSTe4je.js +0 -66
  172. package/src/client/dist/pwa/assets/typescript-CzOp_GqX.js +0 -1
  173. package/src/client/dist/pwa/assets/use-checkbox-E9SV7iH5.js +0 -1
  174. package/src/client/dist/pwa/assets/use-onboarding-B3l7mx48.css +0 -1
  175. package/src/client/dist/pwa/assets/use-onboarding-BGy3sOAN.js +0 -2
  176. package/src/client/dist/pwa/assets/use-quasar-DsmzxifG.js +0 -1
  177. package/src/client/dist/pwa/assets/vue.runtime.esm-bundler-JZnIeD9D.js +0 -3
  178. package/src/client/dist/pwa/assets/workers-0fozwhvS.js +0 -1
  179. package/src/client/dist/pwa/assets/xml-CPa01uHm.js +0 -1
  180. package/src/client/dist/pwa/assets/yaml-Cw3fzSuh.js +0 -1
@@ -1,4 +1,4 @@
1
- import { execSync, spawn } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { getProjectSettings } from './settings-service.js';
@@ -10,6 +10,16 @@ function cleanEnv() {
10
10
  const { PORT, SERVER_PORT, ...rest } = process.env;
11
11
  return rest;
12
12
  }
13
+ function runCommand(command, args, options = {}) {
14
+ return new Promise((resolve, reject) => {
15
+ execFile(command, args, { ...options, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
16
+ if (err)
17
+ reject(err);
18
+ else
19
+ resolve(stdout);
20
+ });
21
+ });
22
+ }
13
23
  // ── State ──────────────────────────────────────────────────────────────────────
14
24
  /** workspaceId -> spawned dev-server process */
15
25
  const trackedProcesses = new Map();
@@ -61,28 +71,32 @@ export function resolveInstance(projectPath, workingBranch) {
61
71
  for (const file of files) {
62
72
  const content = readFileSync(path.join(instancesDir, file), 'utf-8');
63
73
  const parsed = parseEnvFile(content);
64
- if (parsed.INSTANCE_NAME && parsed.INSTANCE_NAME.toLowerCase() === sanitized) {
74
+ if (parsed.INSTANCE_NAME &&
75
+ parsed.INSTANCE_NAME.toLowerCase() === sanitized &&
76
+ parsed.PROJECT_NAME &&
77
+ /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(parsed.PROJECT_NAME)) {
65
78
  return {
66
79
  instanceName: parsed.INSTANCE_NAME,
67
- projectName: parsed.PROJECT_NAME ?? '',
80
+ projectName: parsed.PROJECT_NAME,
68
81
  httpPort: parsed.HTTP_PORT ?? '',
69
82
  };
70
83
  }
71
84
  }
72
85
  return null;
73
86
  }
87
+ function containerBelongsToProject(containerName, projectName) {
88
+ const name = containerName.toLowerCase();
89
+ const project = projectName.toLowerCase();
90
+ return name === project || name.startsWith(`${project}-`) || name.startsWith(`${project}_`);
91
+ }
74
92
  // ── Docker helpers ─────────────────────────────────────────────────────────────
75
93
  /**
76
94
  * List all running Docker container names.
77
- * Note: uses execSync with shell because docker ps --format requires
78
- * Go template syntax with `{{}}`. Input is a static string, no injection risk.
95
+ * Uses execFile so Docker inspection cannot block the Node event loop.
79
96
  */
80
- export function listRunningContainers() {
97
+ export async function listRunningContainers() {
81
98
  try {
82
- const output = execSync('docker ps --format "{{.Names}}"', {
83
- encoding: 'utf-8',
84
- timeout: 10000,
85
- });
99
+ const output = await runCommand('docker', ['ps', '--format', '{{.Names}}'], { timeout: 10_000 });
86
100
  return output
87
101
  .split('\n')
88
102
  .map((s) => s.trim())
@@ -102,7 +116,7 @@ export function listRunningContainers() {
102
116
  * in `docker ps` yet. This prevents the UI from flashing to `'stopped'` during
103
117
  * long build phases.
104
118
  */
105
- export function getStatus(projectPath, workingBranch, workspaceId) {
119
+ export async function getStatus(projectPath, workingBranch, workspaceId) {
106
120
  const config = resolveInstance(projectPath, workingBranch);
107
121
  if (!config) {
108
122
  return {
@@ -114,8 +128,8 @@ export function getStatus(projectPath, workingBranch, workspaceId) {
114
128
  containers: [],
115
129
  };
116
130
  }
117
- const running = listRunningContainers();
118
- const matching = running.filter((name) => name.toLowerCase().includes(config.projectName.toLowerCase()));
131
+ const running = await listRunningContainers();
132
+ const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
119
133
  if (matching.length > 0) {
120
134
  return {
121
135
  status: 'running',
@@ -161,6 +175,13 @@ export function startDevServer(workspaceId) {
161
175
  if (!settings?.devServer.startCommand) {
162
176
  throw new Error('No dev-server start command configured');
163
177
  }
178
+ // Reject a second concurrent start for the same workspace instead of
179
+ // silently overwriting the tracked process — the first process would
180
+ // otherwise become untrackable (never killed by stopDevServer) and its
181
+ // exit handler could later clobber state set by the second process.
182
+ if (trackedProcesses.has(workspaceId)) {
183
+ throw new Error(`Dev server for workspace '${workspaceId}' is already starting`);
184
+ }
164
185
  const instanceName = sanitizeBranchName(workspace.workingBranch);
165
186
  // Execute as bash script (supports multi-line scripts)
166
187
  const worktreePath = workspace.worktreePath;
@@ -185,9 +206,14 @@ export function startDevServer(workspaceId) {
185
206
  });
186
207
  proc.on('exit', (code) => {
187
208
  trackedProcesses.delete(workspaceId);
188
- const currentStatus = getStatus(workspace.projectPath, workspace.workingBranch);
189
- updateDevServerStatus(workspaceId, currentStatus.status);
190
- emitEphemeral(workspaceId, 'devserver:status', currentStatus);
209
+ void getStatus(workspace.projectPath, workspace.workingBranch)
210
+ .then((currentStatus) => {
211
+ updateDevServerStatus(workspaceId, currentStatus.status);
212
+ emitEphemeral(workspaceId, 'devserver:status', currentStatus);
213
+ })
214
+ .catch((err) => {
215
+ console.error(`[dev-server] Failed to refresh status for workspace ${workspaceId}:`, err);
216
+ });
191
217
  if (code !== 0) {
192
218
  console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
193
219
  }
@@ -222,7 +248,7 @@ export function startDevServer(workspaceId) {
222
248
  /**
223
249
  * Stop the dev-server for a workspace.
224
250
  */
225
- export function stopDevServer(workspaceId) {
251
+ export async function stopDevServer(workspaceId) {
226
252
  const workspace = getWorkspace(workspaceId);
227
253
  if (!workspace) {
228
254
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -249,18 +275,15 @@ export function stopDevServer(workspaceId) {
249
275
  }
250
276
  const settings = getProjectSettings(workspace.projectPath);
251
277
  if (settings?.devServer.stopCommand) {
252
- // Custom stop script — run synchronously with instance context in env
253
278
  try {
254
- execSync(settings.devServer.stopCommand, {
279
+ await runCommand('bash', ['-c', settings.devServer.stopCommand], {
255
280
  cwd,
256
281
  env: {
257
282
  ...cleanEnv(),
258
283
  INSTANCE: instanceName,
259
284
  PROJECT_NAME: config?.projectName ?? '',
260
285
  },
261
- encoding: 'utf-8',
262
- timeout: 30000,
263
- shell: 'bash',
286
+ timeout: 30_000,
264
287
  });
265
288
  }
266
289
  catch (err) {
@@ -271,11 +294,7 @@ export function stopDevServer(workspaceId) {
271
294
  // (handles cases where custom stop command doesn't use -p flag)
272
295
  if (config?.projectName) {
273
296
  try {
274
- execSync(`docker compose -p "${config.projectName}" down`, {
275
- cwd: cwd,
276
- encoding: 'utf-8',
277
- timeout: 30000,
278
- });
297
+ await runCommand('docker', ['compose', '-p', config.projectName, 'down'], { cwd, timeout: 30_000 });
279
298
  }
280
299
  catch {
281
300
  // May already be stopped by the custom command — ignore
@@ -296,10 +315,9 @@ export function stopDevServer(workspaceId) {
296
315
  // ── Logs ───────────────────────────────────────────────────────────────────────
297
316
  /**
298
317
  * Get logs from running dev-server containers for a workspace.
299
- * Note: uses execSync for `docker logs` — container names come from
300
- * `docker ps` output (not user input), so no injection risk.
318
+ * Docker log reads run concurrently without blocking the Node event loop.
301
319
  */
302
- export function getDevServerLogs(workspaceId, tail = 200) {
320
+ export async function getDevServerLogs(workspaceId, tail = 200) {
303
321
  const workspace = getWorkspace(workspaceId);
304
322
  if (!workspace) {
305
323
  return 'Workspace not found';
@@ -308,24 +326,20 @@ export function getDevServerLogs(workspaceId, tail = 200) {
308
326
  if (!config) {
309
327
  return 'No dev-server instance found';
310
328
  }
311
- const running = listRunningContainers();
312
- const matching = running.filter((name) => name.toLowerCase().includes(config.projectName.toLowerCase()));
329
+ const running = await listRunningContainers();
330
+ const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
313
331
  if (matching.length === 0) {
314
332
  return 'No running containers found';
315
333
  }
316
- const outputs = [];
317
- for (const container of matching) {
334
+ const outputs = await Promise.all(matching.map(async (container) => {
318
335
  try {
319
- const logs = execSync(`docker logs --tail ${tail} ${container}`, {
320
- encoding: 'utf-8',
321
- timeout: 10000,
322
- });
323
- outputs.push(`=== ${container} ===\n${logs}`);
336
+ const logs = await runCommand('docker', ['logs', '--tail', String(tail), container], { timeout: 10_000 });
337
+ return `=== ${container} ===\n${logs}`;
324
338
  }
325
339
  catch (err) {
326
340
  const message = err instanceof Error ? err.message : String(err);
327
- outputs.push(`=== ${container} ===\n[Error fetching logs: ${message}]`);
341
+ return `=== ${container} ===\n[Error fetching logs: ${message}]`;
328
342
  }
329
- }
343
+ }));
330
344
  return outputs.join('\n\n');
331
345
  }
@@ -144,12 +144,13 @@ function bktEnv() {
144
144
  return process.env;
145
145
  }
146
146
  function bktOptions(repoPath) {
147
- return { cwd: repoPath, encoding: 'utf-8', env: bktEnv() };
147
+ return { cwd: repoPath, encoding: 'utf-8', env: bktEnv(), timeout: 30_000 };
148
148
  }
149
149
  async function resolveTarget(repoPath) {
150
150
  const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
151
151
  cwd: repoPath,
152
152
  encoding: 'utf-8',
153
+ timeout: 30_000,
153
154
  });
154
155
  const target = parseBitbucketRemote(stdout);
155
156
  if (!target)
@@ -158,27 +159,21 @@ async function resolveTarget(repoPath) {
158
159
  // keyring and context access, which cannot prompt from the Kobo server.
159
160
  if (bktEnv().BKT_TOKEN)
160
161
  return { ...target, context: null };
161
- const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], {
162
- cwd: repoPath,
163
- encoding: 'utf-8',
164
- });
162
+ const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], bktOptions(repoPath));
165
163
  const contexts = record(JSON.parse(contextOutput)).contexts;
166
164
  const existing = (Array.isArray(contexts) ? contexts : [])
167
165
  .map(record)
168
166
  .find((context) => string(context.workspace) === target.workspace && string(context.default_repo) === target.repo);
169
167
  if (existing && string(existing.name))
170
168
  return { ...target, context: string(existing.name) };
171
- const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], {
172
- cwd: repoPath,
173
- encoding: 'utf-8',
174
- });
169
+ const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
175
170
  const hosts = record(JSON.parse(authOutput)).hosts;
176
171
  const host = (Array.isArray(hosts) ? hosts : []).map(record).find((item) => string(item.kind) === 'cloud');
177
172
  const hostKey = string(host?.key);
178
173
  if (!hostKey)
179
174
  throw new Error('No authenticated Bitbucket Cloud host found; run bkt auth login first');
180
175
  const context = contextName(target.workspace, target.repo);
181
- await execFileAsync('bkt', ['context', 'create', context, '--host', hostKey, '--workspace', target.workspace, '--repo', target.repo], { cwd: repoPath, encoding: 'utf-8' });
176
+ await execFileAsync('bkt', ['context', 'create', context, '--host', hostKey, '--workspace', target.workspace, '--repo', target.repo], bktOptions(repoPath));
182
177
  return { ...target, context };
183
178
  }
184
179
  function targetArgs(target) {
@@ -286,6 +281,6 @@ export const bitbucketProvider = {
286
281
  await execFileAsync('bkt', bktArgs(target, ['pr', 'merge', String(number), '--close-source=false', ...targetArgs(target)]), bktOptions(repoPath));
287
282
  },
288
283
  async deleteRemoteBranch(repoPath, branchName) {
289
- await execFileAsync('git', ['push', 'origin', '--delete', branchName], { cwd: repoPath, encoding: 'utf-8' });
284
+ await execFileAsync('git', ['push', 'origin', '--delete', branchName], bktOptions(repoPath));
290
285
  },
291
286
  };
@@ -3,6 +3,10 @@ import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { deriveReadyToMerge } from '../types.js';
5
5
  const execFileAsync = promisify(execFile);
6
+ const CLI_TIMEOUT_MS = 30_000;
7
+ function cliOptions(repoPath) {
8
+ return { cwd: repoPath, encoding: 'utf-8', timeout: CLI_TIMEOUT_MS };
9
+ }
6
10
  // NOTE: `reviewThreads` is intentionally NOT in this list.
7
11
  // `gh pr view --json reviewThreads` is rejected with `Unknown JSON field`
8
12
  // — there is no stable `gh` version that exposes it. Until upstream adds it,
@@ -101,7 +105,7 @@ export const githubProvider = {
101
105
  },
102
106
  async isAvailable(repoPath) {
103
107
  try {
104
- await execFileAsync('gh', ['auth', 'status'], { cwd: repoPath, encoding: 'utf-8' });
108
+ await execFileAsync('gh', ['auth', 'status'], cliOptions(repoPath));
105
109
  return { available: true };
106
110
  }
107
111
  catch (err) {
@@ -110,10 +114,7 @@ export const githubProvider = {
110
114
  },
111
115
  async getPrStatus(repoPath, branch) {
112
116
  try {
113
- const { stdout } = await execFileAsync('gh', ['pr', 'view', branch, '--json', GH_PR_FIELDS], {
114
- cwd: repoPath,
115
- encoding: 'utf-8',
116
- });
117
+ const { stdout } = await execFileAsync('gh', ['pr', 'view', branch, '--json', GH_PR_FIELDS], cliOptions(repoPath));
117
118
  const raw = stdout.trim();
118
119
  if (!raw)
119
120
  return null;
@@ -124,19 +125,19 @@ export const githubProvider = {
124
125
  }
125
126
  },
126
127
  async createPr(repoPath, opts) {
127
- const { stdout } = await execFileAsync('gh', ['pr', 'create', '--base', opts.base, '--head', opts.head, '--title', opts.title, '--body', opts.body], { cwd: repoPath, encoding: 'utf-8' });
128
+ const { stdout } = await execFileAsync('gh', ['pr', 'create', '--base', opts.base, '--head', opts.head, '--title', opts.title, '--body', opts.body], cliOptions(repoPath));
128
129
  const match = stdout.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
129
130
  if (!match)
130
131
  throw new Error('Could not parse PR URL from gh output');
131
132
  return { url: match[0], number: Number.parseInt(match[1], 10) };
132
133
  },
133
134
  async changePrBase(repoPath, base) {
134
- await execFileAsync('gh', ['pr', 'edit', '--base', base], { cwd: repoPath, encoding: 'utf-8' });
135
+ await execFileAsync('gh', ['pr', 'edit', '--base', base], cliOptions(repoPath));
135
136
  },
136
137
  async mergeRequest(repoPath, number) {
137
- await execFileAsync('gh', ['pr', 'merge', String(number), '--merge'], { cwd: repoPath, encoding: 'utf-8' });
138
+ await execFileAsync('gh', ['pr', 'merge', String(number), '--merge'], cliOptions(repoPath));
138
139
  },
139
140
  async deleteRemoteBranch(repoPath, branch) {
140
- await execFileAsync('git', ['push', 'origin', '--delete', branch], { cwd: repoPath, encoding: 'utf-8' });
141
+ await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
141
142
  },
142
143
  };
@@ -3,6 +3,10 @@ import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { deriveReadyToMerge } from '../types.js';
5
5
  const execFileAsync = promisify(execFile);
6
+ const CLI_TIMEOUT_MS = 30_000;
7
+ function cliOptions(repoPath) {
8
+ return { cwd: repoPath, encoding: 'utf-8', timeout: CLI_TIMEOUT_MS };
9
+ }
6
10
  /** Map GitLab MR state to the normalised PrSnapshot state. */
7
11
  function mapState(state) {
8
12
  if (state === 'merged')
@@ -100,10 +104,7 @@ function mapGlabJobToCheck(job) {
100
104
  */
101
105
  async function fetchGlabCi(repoPath, branch) {
102
106
  try {
103
- const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'], {
104
- cwd: repoPath,
105
- encoding: 'utf-8',
106
- });
107
+ const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'], cliOptions(repoPath));
107
108
  const raw = stdout.trim();
108
109
  if (!raw)
109
110
  return { rollup: null, checks: [] };
@@ -138,7 +139,7 @@ export const gitlabProvider = {
138
139
  },
139
140
  async isAvailable(repoPath) {
140
141
  try {
141
- await execFileAsync('glab', ['auth', 'status'], { cwd: repoPath, encoding: 'utf-8' });
142
+ await execFileAsync('glab', ['auth', 'status'], cliOptions(repoPath));
142
143
  return { available: true };
143
144
  }
144
145
  catch (err) {
@@ -147,10 +148,7 @@ export const gitlabProvider = {
147
148
  },
148
149
  async getPrStatus(repoPath, branch) {
149
150
  try {
150
- const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'], {
151
- cwd: repoPath,
152
- encoding: 'utf-8',
153
- });
151
+ const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'], cliOptions(repoPath));
154
152
  const raw = stdout.trim();
155
153
  if (!raw)
156
154
  return null;
@@ -176,19 +174,19 @@ export const gitlabProvider = {
176
174
  '--description',
177
175
  opts.body,
178
176
  '--yes',
179
- ], { cwd: repoPath, encoding: 'utf-8' });
177
+ ], cliOptions(repoPath));
180
178
  const match = stdout.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/);
181
179
  if (!match)
182
180
  throw new Error('Could not parse MR URL from glab output');
183
181
  return { url: match[0], number: Number.parseInt(match[1], 10) };
184
182
  },
185
183
  async changePrBase(repoPath, base) {
186
- await execFileAsync('glab', ['mr', 'update', '--target-branch', base], { cwd: repoPath, encoding: 'utf-8' });
184
+ await execFileAsync('glab', ['mr', 'update', '--target-branch', base], cliOptions(repoPath));
187
185
  },
188
186
  async mergeRequest(repoPath, number) {
189
- await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'], { cwd: repoPath, encoding: 'utf-8' });
187
+ await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'], cliOptions(repoPath));
190
188
  },
191
189
  async deleteRemoteBranch(repoPath, branch) {
192
- await execFileAsync('git', ['push', 'origin', '--delete', branch], { cwd: repoPath, encoding: 'utf-8' });
190
+ await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
193
191
  },
194
192
  };
@@ -10,13 +10,15 @@ import { resolveForge } from './forge/resolve.js';
10
10
  */
11
11
  export async function computeGitStats(workspace, prSnapshot) {
12
12
  const { worktreePath, sourceBranch, workingBranch, projectPath } = workspace;
13
- const commitCount = gitOps.getCommitCount(worktreePath, sourceBranch, workingBranch);
14
- const behindCount = gitOps.getCommitsBehind(worktreePath, sourceBranch, workingBranch);
15
- const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, sourceBranch, workingBranch);
16
- const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath, workingBranch);
17
- const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
18
13
  const forgeProvider = getForgeProvider(resolveForge(projectPath));
19
- const availability = await forgeProvider.isAvailable(worktreePath);
14
+ const [commitCount, behindCount, diffStats, unpushedCount, workingTree, availability] = await Promise.all([
15
+ gitOps.getCommitCountAsync(worktreePath, sourceBranch, workingBranch),
16
+ gitOps.getCommitsBehindAsync(worktreePath, sourceBranch, workingBranch),
17
+ gitOps.getStructuredDiffStatsBetweenAsync(worktreePath, sourceBranch, workingBranch),
18
+ gitOps.getUnpushedCountAsync(worktreePath, workingBranch),
19
+ gitOps.getWorkingTreeStatusAsync(worktreePath),
20
+ forgeProvider.isAvailable(worktreePath),
21
+ ]);
20
22
  return {
21
23
  commitCount,
22
24
  behindCount,
@@ -6,6 +6,29 @@ const IMAGES_DIR = '.ai/images';
6
6
  const INDEX_FILE = 'index.json';
7
7
  // Per-worktree lock to serialize index.json writes
8
8
  const locks = new Map();
9
+ function ensureRealImagesDirectory(worktreePath) {
10
+ const realWorktree = fs.realpathSync(worktreePath);
11
+ let current = worktreePath;
12
+ for (const segment of ['.ai', 'images']) {
13
+ current = path.join(current, segment);
14
+ if (fs.existsSync(current)) {
15
+ const stat = fs.lstatSync(current);
16
+ if (stat.isSymbolicLink())
17
+ throw new Error(`Symbolic links are not allowed in image storage: ${current}`);
18
+ if (!stat.isDirectory())
19
+ throw new Error(`Image storage path is not a directory: ${current}`);
20
+ }
21
+ else {
22
+ fs.mkdirSync(current);
23
+ }
24
+ const realCurrent = fs.realpathSync(current);
25
+ const relative = path.relative(realWorktree, realCurrent);
26
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
27
+ throw new Error('Image storage path escapes the worktree');
28
+ }
29
+ }
30
+ return fs.realpathSync(current);
31
+ }
9
32
  function withLock(worktreePath, fn) {
10
33
  const prev = locks.get(worktreePath) ?? Promise.resolve();
11
34
  // The second argument to .then() means: even if the previous operation in the
@@ -18,6 +41,8 @@ function readIndex(imagesDir) {
18
41
  const indexPath = path.join(imagesDir, INDEX_FILE);
19
42
  if (!fs.existsSync(indexPath))
20
43
  return [];
44
+ if (fs.lstatSync(indexPath).isSymbolicLink())
45
+ throw new Error('Symbolic links are not allowed for image index');
21
46
  try {
22
47
  return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
23
48
  }
@@ -27,7 +52,13 @@ function readIndex(imagesDir) {
27
52
  }
28
53
  }
29
54
  function writeIndex(imagesDir, entries) {
30
- fs.writeFileSync(path.join(imagesDir, INDEX_FILE), JSON.stringify(entries, null, 2));
55
+ const indexPath = path.join(imagesDir, INDEX_FILE);
56
+ if (fs.existsSync(indexPath) && fs.lstatSync(indexPath).isSymbolicLink()) {
57
+ throw new Error('Symbolic links are not allowed for image index');
58
+ }
59
+ const tempPath = path.join(imagesDir, `.index-${process.pid}.tmp`);
60
+ fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2));
61
+ fs.renameSync(tempPath, indexPath);
31
62
  }
32
63
  /** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
33
64
  export async function saveImage(worktreePath, fileBuffer, originalName) {
@@ -39,9 +70,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
39
70
  throw new Error(`Unsupported image extension: '${ext}'. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
40
71
  }
41
72
  const uid = nanoid(10);
42
- const imagesDir = path.join(worktreePath, IMAGES_DIR);
43
- // mkdirSync is idempotent — safe to call outside the lock
44
- fs.mkdirSync(imagesDir, { recursive: true });
73
+ const imagesDir = ensureRealImagesDirectory(worktreePath);
45
74
  const filename = `${uid}.${ext}`;
46
75
  await withLock(worktreePath, () => {
47
76
  // Write the image file inside the lock so both the file write and index
@@ -55,7 +84,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
55
84
  }
56
85
  /** Delete an image by UID from disk and the index. */
57
86
  export async function deleteImage(worktreePath, uid) {
58
- const imagesDir = path.join(worktreePath, IMAGES_DIR);
87
+ const imagesDir = ensureRealImagesDirectory(worktreePath);
59
88
  await withLock(worktreePath, () => {
60
89
  const entries = readIndex(imagesDir);
61
90
  const idx = entries.findIndex((e) => e.uid === uid);
@@ -266,7 +266,15 @@ export async function extractNotionPage(notionUrl) {
266
266
  }
267
267
  finally {
268
268
  mcpProcess.stdin?.end();
269
- mcpProcess.kill();
269
+ try {
270
+ if (mcpProcess.pid)
271
+ process.kill(-mcpProcess.pid, 'SIGTERM');
272
+ else
273
+ mcpProcess.kill();
274
+ }
275
+ catch {
276
+ mcpProcess.kill();
277
+ }
270
278
  }
271
279
  }
272
280
  /** Lightweight MCP/auth smoke test; does not read a user page or mutate Notion. */
@@ -285,7 +293,15 @@ export async function testNotionConnection() {
285
293
  }
286
294
  finally {
287
295
  mcpProcess.stdin?.end();
288
- mcpProcess.kill();
296
+ try {
297
+ if (mcpProcess.pid)
298
+ process.kill(-mcpProcess.pid, 'SIGTERM');
299
+ else
300
+ mcpProcess.kill();
301
+ }
302
+ catch {
303
+ mcpProcess.kill();
304
+ }
289
305
  }
290
306
  }
291
307
  export async function listNotionUsers() {
@@ -339,7 +355,15 @@ export async function listNotionUsers() {
339
355
  }
340
356
  finally {
341
357
  mcpProcess.stdin?.end();
342
- mcpProcess.kill();
358
+ try {
359
+ if (mcpProcess.pid)
360
+ process.kill(-mcpProcess.pid, 'SIGTERM');
361
+ else
362
+ mcpProcess.kill();
363
+ }
364
+ catch {
365
+ mcpProcess.kill();
366
+ }
343
367
  }
344
368
  }
345
369
  export async function assignNotionPageToSelf(notionUrl, propertyName) {
@@ -398,7 +422,15 @@ export async function assignNotionPageToSelf(notionUrl, propertyName) {
398
422
  }
399
423
  finally {
400
424
  mcpProcess.stdin?.end();
401
- mcpProcess.kill();
425
+ try {
426
+ if (mcpProcess.pid)
427
+ process.kill(-mcpProcess.pid, 'SIGTERM');
428
+ else
429
+ mcpProcess.kill();
430
+ }
431
+ catch {
432
+ mcpProcess.kill();
433
+ }
402
434
  }
403
435
  }
404
436
  /** Update a status property on a Notion page. Best-effort, does not throw. */
@@ -433,6 +465,14 @@ export async function updateNotionStatus(notionUrl, propertyName, statusValue) {
433
465
  }
434
466
  finally {
435
467
  mcpProcess.stdin?.end();
436
- mcpProcess.kill();
468
+ try {
469
+ if (mcpProcess.pid)
470
+ process.kill(-mcpProcess.pid, 'SIGTERM');
471
+ else
472
+ mcpProcess.kill();
473
+ }
474
+ catch {
475
+ mcpProcess.kill();
476
+ }
437
477
  }
438
478
  }
@@ -18,8 +18,19 @@ import { purgeWorktree } from './worktree-purge-service.js';
18
18
  // it is recorded but NOT acted upon — prevents re-archiving manually
19
19
  // unarchived workspaces.
20
20
  const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
21
+ const WORKSPACE_CHECK_CONCURRENCY = 4;
21
22
  let timer = null;
22
23
  let checking = false;
24
+ async function runBounded(items, concurrency, worker) {
25
+ let nextIndex = 0;
26
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
27
+ while (nextIndex < items.length) {
28
+ const item = items[nextIndex++];
29
+ await worker(item);
30
+ }
31
+ });
32
+ await Promise.all(workers);
33
+ }
23
34
  /** Tracks the last known PR snapshot per workspace, used to detect transitions
24
35
  * (state, base, reviewDecision). */
25
36
  const lastKnownPr = new Map();
@@ -110,11 +121,11 @@ export async function checkPrStatuses() {
110
121
  lastKnownGitStats.delete(id);
111
122
  }
112
123
  }
113
- for (const ws of workspaces) {
124
+ await runBounded(workspaces, WORKSPACE_CHECK_CONCURRENCY, async (ws) => {
114
125
  // Without this guard, every git/forge spawn below fails with ENOENT and
115
126
  // floods the logs when a worktree was deleted externally.
116
127
  if (!fs.existsSync(ws.worktreePath))
117
- continue;
128
+ return;
118
129
  try {
119
130
  // Opt-out: skip the forge call entirely for a workspace with PR-watch
120
131
  // disabled — no PR-status fetch, no auto-archive/purge, no PR-derived
@@ -143,7 +154,7 @@ export async function checkPrStatuses() {
143
154
  console.error(`[pr-watcher] updateWorkspaceSourceBranch failed for '${ws.name}':`, err instanceof Error ? err.message : err);
144
155
  // Leave the cache untouched so the next tick retries — and skip
145
156
  // stats too, since they'd be computed against the stale base.
146
- continue;
157
+ return;
147
158
  }
148
159
  }
149
160
  }
@@ -158,7 +169,7 @@ export async function checkPrStatuses() {
158
169
  console.error(`[pr-watcher] computeGitStats failed for '${ws.name}':`, err instanceof Error ? err.message : err);
159
170
  }
160
171
  if (!pr)
161
- continue;
172
+ return;
162
173
  const prev = lastKnownPr.get(ws.id);
163
174
  // We delay updating `lastKnownPr` until after the actions succeed.
164
175
  // Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
@@ -179,13 +190,13 @@ export async function checkPrStatuses() {
179
190
  // (The defensive base preservation from the no-base branch doesn't apply here
180
191
  // because we ARE in the OPEN→MERGED/CLOSED branch which always has a base.)
181
192
  lastKnownPr.set(ws.id, pr);
182
- continue;
193
+ return;
183
194
  }
184
195
  console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
185
196
  // Best-effort cleanup (same as manual archive): stop dev server + terminal.
186
197
  // Agent is already not running here (guarded above).
187
198
  try {
188
- stopDevServer(ws.id);
199
+ await stopDevServer(ws.id);
189
200
  }
190
201
  catch (err) {
191
202
  console.error(`[pr-watcher] stopDevServer failed for '${ws.name}':`, err instanceof Error ? err.message : err);
@@ -217,7 +228,7 @@ export async function checkPrStatuses() {
217
228
  console.error(`[pr-watcher] auto-purge guard failed for '${ws.name}':`, err instanceof Error ? err.message : err);
218
229
  }
219
230
  }
220
- continue; // do not run base-change detection on a workspace we just archived
231
+ return; // do not run base-change detection on a workspace we just archived
221
232
  }
222
233
  // Review, CI, mergeability, and readiness transitions (only on OPEN PRs;
223
234
  // first-sight is silent). Each check is independent so simultaneous
@@ -254,7 +265,7 @@ export async function checkPrStatuses() {
254
265
  if (pr.state !== 'OPEN' || !pr.base) {
255
266
  const next = pr.base ? pr : { ...pr, base: prev?.base ?? pr.base };
256
267
  lastKnownPr.set(ws.id, next);
257
- continue;
268
+ return;
258
269
  }
259
270
  lastKnownPr.set(ws.id, pr);
260
271
  // Emit the base-change event AFTER the snapshot is committed so a
@@ -272,7 +283,7 @@ export async function checkPrStatuses() {
272
283
  catch (err) {
273
284
  console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
274
285
  }
275
- }
286
+ });
276
287
  }
277
288
  /**
278
289
  * On-demand refresh of a single workspace's PR snapshot. Bypasses the 30s tick.