@loicngr/kobo 1.11.45 → 2.1.1

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 (181) hide show
  1. package/AGENTS.md +17 -2
  2. package/CHANGELOG.md +12 -0
  3. package/CODE_OF_CONDUCT.md +16 -0
  4. package/CONFIGURATION.md +1968 -0
  5. package/CONTRIBUTING.md +83 -0
  6. package/DESIGN.md +296 -0
  7. package/README.md +35 -7
  8. package/SECURITY.md +54 -0
  9. package/THIRD_PARTY_NOTICES.md +20 -0
  10. package/dist/server/db/migrations.js +31 -0
  11. package/dist/server/db/schema.js +4 -0
  12. package/dist/server/index.js +6 -0
  13. package/dist/server/routes/environment.js +18 -0
  14. package/dist/server/routes/health.js +17 -2
  15. package/dist/server/routes/integrations.js +29 -0
  16. package/dist/server/routes/settings.js +5 -0
  17. package/dist/server/routes/sounds.js +60 -0
  18. package/dist/server/routes/usage.js +28 -2
  19. package/dist/server/routes/workspaces.js +46 -12
  20. package/dist/server/services/agent/engines/claude-code/engine.js +37 -4
  21. package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
  22. package/dist/server/services/agent/engines/codex/spawn.js +2 -2
  23. package/dist/server/services/agent/integration-mcp.js +49 -0
  24. package/dist/server/services/agent/orchestrator.js +6 -2
  25. package/dist/server/services/auto-loop-service.js +45 -25
  26. package/dist/server/services/custom-sound-service.js +0 -0
  27. package/dist/server/services/environment-check-service.js +125 -0
  28. package/dist/server/services/initial-prompt-template-service.js +8 -21
  29. package/dist/server/services/integration-config-service.js +65 -0
  30. package/dist/server/services/legacy-public-prompts.js +112 -0
  31. package/dist/server/services/notion-service.js +25 -61
  32. package/dist/server/services/sentry-service.js +17 -12
  33. package/dist/server/services/settings-defaults.js +5 -0
  34. package/dist/server/services/settings-service.js +112 -23
  35. package/dist/server/services/skill-suite-prompts.js +29 -22
  36. package/dist/server/services/templates-service.js +31 -8
  37. package/dist/server/services/terminal-service.js +16 -0
  38. package/dist/server/services/usage/reliability.js +24 -0
  39. package/dist/server/services/wakeup-service.js +7 -5
  40. package/dist/server/services/workspace-service.js +25 -4
  41. package/dist/server/services/worktree-service.js +3 -1
  42. package/dist/server/utils/git-repo-lock.js +7 -3
  43. package/dist/server/utils/mcp-client.js +26 -12
  44. package/dist/server/utils/mcp-process.js +25 -0
  45. package/dist/server/utils/node-pty-spawn-helper.js +31 -0
  46. package/dist/server/utils/paths.js +7 -0
  47. package/dist/shared/auto-loop-prompts.js +6 -2
  48. package/dist/shared/automatic-admission.js +1 -0
  49. package/dist/shared/environment.js +1 -0
  50. package/dist/shared/notification-assets.js +73 -0
  51. package/dist/shared/skill-suite-prompts.js +6 -3
  52. package/dist/shared/workflow-policy.js +25 -0
  53. package/docs/getting-started.md +47 -0
  54. package/docs/troubleshooting.md +49 -0
  55. package/package.json +19 -7
  56. package/src/client/dist/pwa/assets/ActivityFeed-DeJ0V6CW.js +8 -0
  57. package/src/client/dist/pwa/assets/{ChangelogPage-CnXTnFhe.js → ChangelogPage-OoOnn4q9.js} +1 -1
  58. package/src/client/dist/pwa/assets/{ComparisonPanel-CKylN45q.js → ComparisonPanel-BYpe6Nch.js} +2 -2
  59. package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +1 -0
  60. package/src/client/dist/pwa/assets/CreatePage-COQwHyqL.js +2 -0
  61. package/src/client/dist/pwa/assets/DashboardPage-CZ2036MC.js +1 -0
  62. package/src/client/dist/pwa/assets/DashboardPage-XiCj24i2.css +1 -0
  63. package/src/client/dist/pwa/assets/DiffViewer-CXBmMV9R.js +8 -0
  64. package/src/client/dist/pwa/assets/{DocumentsPanel-BeCttB-x.js → DocumentsPanel-mLGU5aTb.js} +1 -1
  65. package/src/client/dist/pwa/assets/{DrawerToggleButton-BJBBYeOc.js → DrawerToggleButton-C39pfWBD.js} +1 -1
  66. package/src/client/dist/pwa/assets/GitPanel-BQk7kJO-.js +3 -0
  67. package/src/client/dist/pwa/assets/{GitPanel-BFCnyhE2.css → GitPanel-C4wOcZV2.css} +1 -1
  68. package/src/client/dist/pwa/assets/{HealthPage-l88TEk-k.js → HealthPage-CnJWXfa8.js} +1 -1
  69. package/src/client/dist/pwa/assets/MainLayout-Cc4XzCAy.css +1 -0
  70. package/src/client/dist/pwa/assets/MainLayout-D3zfT61Y.js +2 -0
  71. package/src/client/dist/pwa/assets/{QBtnDropdown-44Mj-y_1.js → QBtnDropdown-DGLLlBf3.js} +1 -1
  72. package/src/client/dist/pwa/assets/{QExpansionItem-Bok1m8Wi.js → QExpansionItem-CQ2LzM8P.js} +1 -1
  73. package/src/client/dist/pwa/assets/QSelect-DoTWgQAO.js +1 -0
  74. package/src/client/dist/pwa/assets/{QTooltip-BDihn2aY.js → QTooltip-DKvUeiR_.js} +1 -1
  75. package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +1 -0
  76. package/src/client/dist/pwa/assets/SchedulePanel-DIMzNIO3.js +1 -0
  77. package/src/client/dist/pwa/assets/{SearchPage-BwZ2TX1G.js → SearchPage-COWnMxRZ.js} +1 -1
  78. package/src/client/dist/pwa/assets/SettingsPage-B9cLhFnO.js +9 -0
  79. package/src/client/dist/pwa/assets/{SettingsPage-W6N01tGz.css → SettingsPage-BYdLXJEJ.css} +1 -1
  80. package/src/client/dist/pwa/assets/SplitWorkspacePage-BJKssJyI.js +1 -0
  81. package/src/client/dist/pwa/assets/{TerminalPanel-Cq-FR7A7.js → TerminalPanel-C_HNpN1X.js} +4 -4
  82. package/src/client/dist/pwa/assets/{TouchPan-BQ1cvzRC.js → TouchPan-CCllyaAF.js} +1 -1
  83. package/src/client/dist/pwa/assets/{TourReplayButton-Dz1yEz_z.js → TourReplayButton-Cp2UnxWa.js} +1 -1
  84. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-B-SuQuS_.js +1 -0
  85. package/src/client/dist/pwa/assets/{WorkspacePage-BbUvR1Vf.css → WorkspacePage--5HsnMQ1.css} +1 -1
  86. package/src/client/dist/pwa/assets/WorkspacePage-6lN4iF5W.js +4 -0
  87. package/src/client/dist/pwa/assets/{browser-BmWgBx9M.js → browser-uy3hmwKo.js} +1 -1
  88. package/src/client/dist/pwa/assets/{build-path-tree-6EjBmYYY.js → build-path-tree-DZrp6Aji.js} +1 -1
  89. package/src/client/dist/pwa/assets/custom-sounds-Du8ReMlU.js +1 -0
  90. package/src/client/dist/pwa/assets/de-CTE3Xb7Z.js +17 -0
  91. package/src/client/dist/pwa/assets/{documents-BZ8bwcjr.js → documents-BtNrB6d0.js} +1 -1
  92. package/src/client/dist/pwa/assets/{editor.api-CK7--ckY.js → editor.api-P-eJRcVK.js} +1 -1
  93. package/src/client/dist/pwa/assets/efforts-ChUC4xtk.js +9 -0
  94. package/src/client/dist/pwa/assets/engineFeatures-D-0NPZBE.js +1 -0
  95. package/src/client/dist/pwa/assets/es-CZxg1xjh.js +17 -0
  96. package/src/client/dist/pwa/assets/{expand-template-C8VyjASo.js → expand-template-ByoC_kxX.js} +1 -1
  97. package/src/client/dist/pwa/assets/{format-LtTLLitJ.js → format-CeidAPFn.js} +7 -7
  98. package/src/client/dist/pwa/assets/{formatters-Bl9zA7Ai.js → formatters-BtG6lb4d.js} +1 -1
  99. package/src/client/dist/pwa/assets/fr-C7HCclxj.js +17 -0
  100. package/src/client/dist/pwa/assets/{freemarker2-DxezMaje.js → freemarker2-CDYXi2tK.js} +1 -1
  101. package/src/client/dist/pwa/assets/{handlebars-DK_Bj6Rx.js → handlebars-AVkJuN-_.js} +1 -1
  102. package/src/client/dist/pwa/assets/{html-DFfsfStD.js → html-CpvEZlmO.js} +1 -1
  103. package/src/client/dist/pwa/assets/i18n-KVkTJG-4.js +1 -0
  104. package/src/client/dist/pwa/assets/index-DsaWUT_Z.js +20 -0
  105. package/src/client/dist/pwa/assets/it-DoOWwqZz.js +17 -0
  106. package/src/client/dist/pwa/assets/{javascript-DuNbcaL7.js → javascript-BYnnBLH9.js} +1 -1
  107. package/src/client/dist/pwa/assets/{liquid-CM3JKyhv.js → liquid-CzqlB3Um.js} +1 -1
  108. package/src/client/dist/pwa/assets/{mdx-BG3zYwNF.js → mdx-hrYFnJVZ.js} +1 -1
  109. package/src/client/dist/pwa/assets/{monaco-contributions-DLscZGH-.js → monaco-contributions-D2YXqNaH.js} +1 -1
  110. package/src/client/dist/pwa/assets/{monaco.contribution-DnhZHwXT.js → monaco.contribution-Dl22KqTL.js} +2 -2
  111. package/src/client/dist/pwa/assets/network-auth-Dq0I5kjv.js +1 -0
  112. package/src/client/dist/pwa/assets/notify-theme-vV3Rm4_v.js +1 -0
  113. package/src/client/dist/pwa/assets/{pending-diff-open-dYIFdS4r.js → pending-diff-open-BJYqBOUG.js} +1 -1
  114. package/src/client/dist/pwa/assets/permissionModes-B-ciuUbz.js +36 -0
  115. package/src/client/dist/pwa/assets/{project-color-R_1je4vW.js → project-color-W5mMgIKK.js} +1 -1
  116. package/src/client/dist/pwa/assets/{python-COs6pHk2.js → python-DafgCozG.js} +1 -1
  117. package/src/client/dist/pwa/assets/{razor-Jl1VqMsk.js → razor-CWYrTLCm.js} +1 -1
  118. package/src/client/dist/pwa/assets/render-chat-markdown-CE5RIL0s.js +69 -0
  119. package/src/client/dist/pwa/assets/{typescript-BU_TUuAM.js → typescript-DLSIuW9u.js} +1 -1
  120. package/src/client/dist/pwa/assets/use-tours-CDdnvIaG.js +2 -0
  121. package/src/client/dist/pwa/assets/workspace-templates-C88VmFdQ.js +1 -0
  122. package/src/client/dist/pwa/assets/{xml-gFFsobxE.js → xml-BAmdYarn.js} +1 -1
  123. package/src/client/dist/pwa/assets/{yaml-DaXaqaGD.js → yaml-RQqNMXWh.js} +1 -1
  124. package/src/client/dist/pwa/index.html +1 -1
  125. package/src/client/dist/pwa/sounds/neutral.wav +0 -0
  126. package/src/client/dist/pwa/sounds/ready.wav +0 -0
  127. package/src/client/dist/pwa/sw.js +1 -1
  128. package/src/client/dist/pwa/assets/ActivityFeed-CLXjdqS8.js +0 -8
  129. package/src/client/dist/pwa/assets/CreatePage-SoCcvxuD.js +0 -2
  130. package/src/client/dist/pwa/assets/CreatePage-TY9g3hZb.css +0 -1
  131. package/src/client/dist/pwa/assets/DashboardPage-CZQLfoV1.js +0 -1
  132. package/src/client/dist/pwa/assets/DashboardPage-D8g7_r4S.css +0 -1
  133. package/src/client/dist/pwa/assets/DiffViewer-Csf3WmLc.js +0 -8
  134. package/src/client/dist/pwa/assets/GitPanel-B-ZHgghQ.js +0 -3
  135. package/src/client/dist/pwa/assets/MainLayout-BAAQLq95.css +0 -1
  136. package/src/client/dist/pwa/assets/MainLayout-Cf4Q9x1u.js +0 -2
  137. package/src/client/dist/pwa/assets/QDrawer-B4qHw8wt.js +0 -1
  138. package/src/client/dist/pwa/assets/QSelect-BbtD5Iks.js +0 -1
  139. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +0 -1
  140. package/src/client/dist/pwa/assets/SchedulePanel-DiWCDNZ8.js +0 -1
  141. package/src/client/dist/pwa/assets/SettingsPage-BUh0WfNf.js +0 -9
  142. package/src/client/dist/pwa/assets/SplitWorkspacePage-Bg6nKBcA.js +0 -1
  143. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-BCVOP8vp.js +0 -1
  144. package/src/client/dist/pwa/assets/WorkspacePage-Cl8gap4b.js +0 -4
  145. package/src/client/dist/pwa/assets/de-COObokpu.js +0 -17
  146. package/src/client/dist/pwa/assets/efforts-Bw0o2Bud.js +0 -9
  147. package/src/client/dist/pwa/assets/engineFeatures-DQO9HHXI.js +0 -1
  148. package/src/client/dist/pwa/assets/es-By2Q9Tca.js +0 -17
  149. package/src/client/dist/pwa/assets/fouet-ahh-DdxrUXRj.mp3 +0 -0
  150. package/src/client/dist/pwa/assets/fr-D-sd0ddn.js +0 -17
  151. package/src/client/dist/pwa/assets/i18n-aDWy4KHt.js +0 -1
  152. package/src/client/dist/pwa/assets/index-BLd8DAzg.js +0 -20
  153. package/src/client/dist/pwa/assets/it-CeK5lyIA.js +0 -17
  154. package/src/client/dist/pwa/assets/network-auth-BJiPbO5l.js +0 -1
  155. package/src/client/dist/pwa/assets/notify-theme-D4p3qPkp.js +0 -1
  156. package/src/client/dist/pwa/assets/permissionModes-B7fuUegU.js +0 -36
  157. package/src/client/dist/pwa/assets/render-chat-markdown-AaOo0Tgq.js +0 -67
  158. package/src/client/dist/pwa/assets/use-tours-CVn874kD.js +0 -2
  159. package/src/client/dist/pwa/assets/workspace-templates-BCKR8TXr.js +0 -1
  160. package/src/client/dist/pwa/sounds/7eme-compagnie-03.mp3 +0 -0
  161. package/src/client/dist/pwa/sounds/aller-ftg.mp3 +0 -0
  162. package/src/client/dist/pwa/sounds/arrete-de-mentir.mp3 +0 -0
  163. package/src/client/dist/pwa/sounds/arretez-les-messages.mp3 +0 -0
  164. package/src/client/dist/pwa/sounds/bah-alors-on-est-nul.mp3 +0 -0
  165. package/src/client/dist/pwa/sounds/basic-notification.mp3 +0 -0
  166. package/src/client/dist/pwa/sounds/ca_va_peter.mp3 +0 -0
  167. package/src/client/dist/pwa/sounds/dry-fart.mp3 +0 -0
  168. package/src/client/dist/pwa/sounds/faaah.mp3 +0 -0
  169. package/src/client/dist/pwa/sounds/for-shure.mp3 +0 -0
  170. package/src/client/dist/pwa/sounds/gta-v-death.mp3 +0 -0
  171. package/src/client/dist/pwa/sounds/hey.mp3 +0 -0
  172. package/src/client/dist/pwa/sounds/mais-laisse-moi-dormir-zebi.mp3 +0 -0
  173. package/src/client/dist/pwa/sounds/nan-tu-degages.mp3 +0 -0
  174. package/src/client/dist/pwa/sounds/nan-wallah-pardon.mp3 +0 -0
  175. package/src/client/dist/pwa/sounds/ouais-cest-greg.mp3 +0 -0
  176. package/src/client/dist/pwa/sounds/pas-ca-zinedine.mp3 +0 -0
  177. package/src/client/dist/pwa/sounds/ta-gueule.mp3 +0 -0
  178. package/src/client/dist/pwa/sounds/travail_termine.mp3 +0 -0
  179. package/src/client/dist/pwa/sounds/tu-vas-la-fermer.mp3 +0 -0
  180. package/src/client/dist/pwa/sounds/warcraft-3-humain-travail.mp3 +0 -0
  181. package/src/client/dist/pwa/sounds/yaaa.mp3 +0 -0
@@ -0,0 +1,18 @@
1
+ import { Hono } from 'hono';
2
+ import { getEnvironmentReport } from '../services/environment-check-service.js';
3
+ const app = new Hono();
4
+ app.get('/', async (c) => {
5
+ const engine = c.req.query('engine') ?? 'claude-code';
6
+ const projectPath = c.req.query('projectPath')?.trim();
7
+ if (!['claude-code', 'codex'].includes(engine) ||
8
+ (projectPath && (projectPath.length > 4096 || projectPath.includes('\0')))) {
9
+ return c.json({ error: 'Invalid environment check request' }, 400);
10
+ }
11
+ try {
12
+ return c.json(await getEnvironmentReport({ engine: engine, projectPath }));
13
+ }
14
+ catch {
15
+ return c.json({ error: 'Environment check unavailable; retry shortly' }, 503);
16
+ }
17
+ });
18
+ export default app;
@@ -5,6 +5,7 @@ import { Hono } from 'hono';
5
5
  import { getDb } from '../db/index.js';
6
6
  import { SCHEMA_VERSION } from '../db/migrations.js';
7
7
  import { resolveCodexBinary } from '../services/agent/engines/codex/spawn.js';
8
+ import { hasIntegrationMcpConfig } from '../services/agent/integration-mcp.js';
8
9
  import { getGlobalSettings, getProjectSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
9
10
  import { readRecentLogs } from '../utils/logger.js';
10
11
  import { getDbPath, getKoboHome, getPackageVersion } from '../utils/paths.js';
@@ -12,6 +13,14 @@ import { slugifyProjectName } from '../utils/project-slug.js';
12
13
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
13
14
  const app = new Hono();
14
15
  const execFileAsync = promisify(execFileCb);
16
+ function integrationConfigured(integration, key) {
17
+ try {
18
+ return hasIntegrationMcpConfig(integration, key);
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
15
24
  async function checkCli(binary) {
16
25
  try {
17
26
  const { stdout } = await execFileAsync(binary, ['--version'], { encoding: 'utf-8', timeout: 2_000 });
@@ -182,8 +191,14 @@ app.get('/report', async (c) => {
182
191
  },
183
192
  agentSessions: { orphaned },
184
193
  integrations: {
185
- notion: { configured: Boolean(healthGlobalSettings.notionMcpKey), enabled: healthGlobalSettings.notionEnabled },
186
- sentry: { configured: Boolean(healthGlobalSettings.sentryMcpKey), enabled: healthGlobalSettings.sentryEnabled },
194
+ notion: {
195
+ configured: integrationConfigured('notion', healthGlobalSettings.notionMcpKey),
196
+ enabled: healthGlobalSettings.notionEnabled,
197
+ },
198
+ sentry: {
199
+ configured: integrationConfigured('sentry', healthGlobalSettings.sentryMcpKey),
200
+ enabled: healthGlobalSettings.sentryEnabled,
201
+ },
187
202
  editor: { configured: Boolean(healthGlobalSettings.editorCommand) },
188
203
  },
189
204
  active: {
@@ -0,0 +1,29 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import { getIntegrationStatus, saveIntegrationConfig, } from '../services/integration-config-service.js';
4
+ const app = new Hono();
5
+ app.use('*', bodyLimit({ maxSize: 64 * 1024 }));
6
+ app.use('/:integration', async (c, next) => {
7
+ if (!['notion', 'sentry'].includes(c.req.param('integration') ?? ''))
8
+ return c.json({ error: 'Unknown integration' }, 404);
9
+ await next();
10
+ });
11
+ app.get('/:integration', (c) => {
12
+ try {
13
+ return c.json(getIntegrationStatus(c.req.param('integration')));
14
+ }
15
+ catch {
16
+ return c.json({ error: 'Integration configuration is unavailable' }, 503);
17
+ }
18
+ });
19
+ app.put('/:integration', async (c) => {
20
+ try {
21
+ const value = await c.req.json();
22
+ saveIntegrationConfig(c.req.param('integration'), value);
23
+ return c.json(getIntegrationStatus(c.req.param('integration')));
24
+ }
25
+ catch {
26
+ return c.json({ error: 'Cannot save integration configuration; check the connection fields and local storage' }, 400);
27
+ }
28
+ });
29
+ export default app;
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { isValidSkillSuite } from '../../shared/skill-suite-prompts.js';
3
+ import { isWorkflowPolicy } from '../../shared/workflow-policy.js';
3
4
  import { getDb } from '../db/index.js';
4
5
  import { getBackendPort } from '../services/agent/orchestrator.js';
5
6
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '../services/initial-prompt-template-service.js';
@@ -163,6 +164,8 @@ app.put('/global', async (c) => {
163
164
  // must answer 400 rather than do that.
164
165
  if (!body)
165
166
  return c.json({ error: 'Invalid JSON body' }, 400);
167
+ if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
168
+ return c.json({ error: 'Invalid workflowPolicy' }, 400);
166
169
  const updated = settingsService.updateGlobalSettings(body);
167
170
  // The client assigns this response straight into its store, so echoing the
168
171
  // real credentials back would undo the masking on GET the first time
@@ -211,6 +214,8 @@ app.put('/projects/:encodedPath', async (c) => {
211
214
  // `{}` CREATES a project entry with defaults; a malformed body must not.
212
215
  if (!body)
213
216
  return c.json({ error: 'Invalid JSON body' }, 400);
217
+ if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
218
+ return c.json({ error: 'Invalid workflowPolicy' }, 400);
214
219
  const project = settingsService.upsertProject(projectPath, body);
215
220
  return c.json(project);
216
221
  }
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import { Hono } from 'hono';
3
+ import { bodyLimit } from 'hono/body-limit';
4
+ import { MAX_CUSTOM_SOUND_BYTES } from '../../shared/notification-assets.js';
5
+ import { CustomSoundError, deleteCustomSound, listCustomSounds, readCustomSound, saveCustomSound, } from '../services/custom-sound-service.js';
6
+ /** Multipart framing and the field name add a small, bounded overhead. */
7
+ const MAX_REQUEST_BYTES = MAX_CUSTOM_SOUND_BYTES + 64 * 1024;
8
+ const app = new Hono();
9
+ // `listCustomSounds` degrades a damaged manifest to an empty list; it never throws.
10
+ app.get('/', (c) => c.json({ sounds: listCustomSounds() }));
11
+ app.post('/', bodyLimit({ maxSize: MAX_REQUEST_BYTES }), async (c) => {
12
+ let form;
13
+ try {
14
+ form = await c.req.raw.formData();
15
+ }
16
+ catch {
17
+ return c.json({ error: 'Invalid multipart body' }, 400);
18
+ }
19
+ const files = form.getAll('sound');
20
+ if (files.length !== 1 || !(files[0] instanceof File))
21
+ return c.json({ error: 'Expected one sound file' }, 400);
22
+ try {
23
+ return c.json(await saveCustomSound(files[0]), 201);
24
+ }
25
+ catch (error) {
26
+ // Only the stable code crosses the boundary: a filesystem error may carry
27
+ // the layout of the user's home directory.
28
+ if (error instanceof CustomSoundError)
29
+ return c.json({ error: 'Invalid sound file', code: error.code }, 400);
30
+ return c.json({ error: 'Cannot store the sound file' }, 500);
31
+ }
32
+ });
33
+ app.delete('/:id', (c) => {
34
+ try {
35
+ return deleteCustomSound(c.req.param('id')) ? c.body(null, 204) : c.json({ error: 'Sound not found' }, 404);
36
+ }
37
+ catch {
38
+ return c.json({ error: 'Cannot remove the sound file' }, 500);
39
+ }
40
+ });
41
+ // An unknown or deleted sound answers 404; the client then falls back to a bundled tone.
42
+ app.get('/:id/file', async (c) => {
43
+ const sound = readCustomSound(c.req.param('id'));
44
+ if (!sound)
45
+ return c.json({ error: 'Sound not found' }, 404);
46
+ let content;
47
+ try {
48
+ content = await fs.promises.readFile(sound.filePath);
49
+ }
50
+ catch {
51
+ // The file vanished between the manifest lookup and the read.
52
+ return c.json({ error: 'Sound not found' }, 404);
53
+ }
54
+ c.header('Content-Type', sound.contentType);
55
+ // Content-addressed by a generated id: the bytes behind an id never change.
56
+ c.header('Cache-Control', 'private, max-age=3600, immutable');
57
+ c.header('X-Content-Type-Options', 'nosniff');
58
+ return c.body(new Uint8Array(content));
59
+ });
60
+ export default app;
@@ -1,12 +1,38 @@
1
1
  import { Hono } from 'hono';
2
2
  import { getDb } from '../db/index.js';
3
3
  import { refreshNow } from '../services/usage/poller.js';
4
- import { computeEngineReliability } from '../services/usage/reliability.js';
4
+ import { clearReliabilityReset, computeEngineReliability, getReliabilityResetAt, resetReliability, } from '../services/usage/reliability.js';
5
5
  const app = new Hono();
6
6
  // GET /api/usage/reliability — how each engine and model actually behaved.
7
7
  app.get('/reliability', (c) => {
8
8
  try {
9
- return c.json({ engines: computeEngineReliability(getDb()) });
9
+ const db = getDb();
10
+ return c.json({ engines: computeEngineReliability(db), resetAt: getReliabilityResetAt(db) });
11
+ }
12
+ catch (err) {
13
+ const message = err instanceof Error ? err.message : String(err);
14
+ return c.json({ error: message }, 500);
15
+ }
16
+ });
17
+ // POST /api/usage/reliability/reset - count again from now. Sessions are kept.
18
+ // Declared before `/:providerId/refresh` so the static path is never captured.
19
+ app.post('/reliability/reset', (c) => {
20
+ try {
21
+ const db = getDb();
22
+ const resetAt = resetReliability(db);
23
+ return c.json({ engines: computeEngineReliability(db), resetAt });
24
+ }
25
+ catch (err) {
26
+ const message = err instanceof Error ? err.message : String(err);
27
+ return c.json({ error: message }, 500);
28
+ }
29
+ });
30
+ // DELETE /api/usage/reliability/reset - count the whole history again.
31
+ app.delete('/reliability/reset', (c) => {
32
+ try {
33
+ const db = getDb();
34
+ clearReliabilityReset(db);
35
+ return c.json({ engines: computeEngineReliability(db), resetAt: null });
10
36
  }
11
37
  catch (err) {
12
38
  const message = err instanceof Error ? err.message : String(err);
@@ -1,11 +1,12 @@
1
1
  import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { nanoid } from 'nanoid';
4
+ import { isWorkflowPolicy, resolveWorkflowPolicy, WORKFLOW_ACTIONS, } from '../../shared/workflow-policy.js';
4
5
  import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
5
6
  import { createSessionHandoff, decideSessionHandoff, getCurrentSessionHandoff, SessionHandoffError, submitSessionHandoff, waitForSessionHandoff, } from '../services/session-handoff-service.js';
6
7
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
7
8
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
8
- import { workspaceLifecycleReason } from '../utils/workspace-lifecycle-guard.js';
9
+ import { isWorkspaceLifecycleBusy, workspaceLifecycleReason } from '../utils/workspace-lifecycle-guard.js';
9
10
  import autoLoopMessagesRoutes from './auto-loop-messages.js';
10
11
  const execFileAsync = promisify(execFileCb);
11
12
  import fs from 'node:fs';
@@ -62,6 +63,15 @@ import { WorkspaceLifecycleBusyError, withWorkspaceLifecycleGuard } from '../uti
62
63
  import { resolveExtractedName } from '../utils/workspace-name.js';
63
64
  import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
64
65
  /** Lifecycle conflicts can be retried once the current owner has stopped. */
66
+ /** Symlink-resolved path, or the lexically resolved one when it does not exist. */
67
+ function realPathOrResolved(p) {
68
+ try {
69
+ return fs.realpathSync(p);
70
+ }
71
+ catch {
72
+ return path.resolve(p);
73
+ }
74
+ }
65
75
  function workspaceErrorStatus(err) {
66
76
  return err instanceof AgentStopError || err instanceof WorkspaceLifecycleBusyError ? 409 : 500;
67
77
  }
@@ -452,6 +462,8 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
452
462
  let setupScriptConfigured = false;
453
463
  try {
454
464
  const { body, attachments } = await readWorkspaceCreationRequest(c.req.raw);
465
+ if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
466
+ return c.json({ error: 'Invalid workflowPolicy' }, 400);
455
467
  // workingBranch is derived from git when worktreePath is provided, so
456
468
  // it's not required in that flow. The other 3 fields stay mandatory.
457
469
  if (!body.name || !body.projectPath || !body.sourceBranch) {
@@ -552,11 +564,10 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
552
564
  return c.json({ error: failure, step: 'inspect-worktree' }, 422);
553
565
  }
554
566
  try {
555
- const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--git-common-dir'], {
556
- encoding: 'utf-8',
557
- }).trim();
558
- const expectedCommonDir = path.join(body.projectPath, '.git');
559
- if (path.resolve(commonDir) !== path.resolve(expectedCommonDir)) {
567
+ const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf-8' }).trim();
568
+ // Git reports the common dir symlink-resolved while the project path may be
569
+ // reached through a link (macOS temp dirs: /var -> /private/var).
570
+ if (realPathOrResolved(commonDir) !== realPathOrResolved(path.join(body.projectPath, '.git'))) {
560
571
  const failure = `Worktree '${body.worktreePath}' belongs to a different repository`;
561
572
  emitCreateFailed(creationId, 'inspect-worktree', failure);
562
573
  return c.json({ error: failure, step: 'inspect-worktree' }, 422);
@@ -794,6 +805,7 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
794
805
  model: body.model,
795
806
  brainstormModel: body.brainstormModel,
796
807
  reasoningEffort: body.reasoningEffort,
808
+ workflowPolicy: resolveWorkflowPolicy(globalSettings.workflowPolicy, projectSettings?.workflowPolicy, body.workflowPolicy),
797
809
  agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings, body.engine ?? 'claude-code'),
798
810
  engine: body.engine,
799
811
  // Set by the engine-comparison flow, which posts here once per engine
@@ -1109,9 +1121,9 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
1109
1121
  `## Additional Context\n${extra}\n\n` +
1110
1122
  `## MCP Tools for deeper analysis\n` +
1111
1123
  `If you need more context, the following Sentry MCP tools are available:\n` +
1112
- `- \`mcp__sentry__get_sentry_resource(url, resourceType)\` — fetch the issue, breadcrumbs, replay, or trace\n` +
1113
- `- \`mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}')\` — recent events for this issue\n` +
1114
- `- \`mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key)\` — filter by tag (environment, user, browser, …)\n`;
1124
+ `- \`get_sentry_resource(url, resourceType) [use the Kōbō-managed Sentry namespace supplied for this session]\` — fetch the issue, breadcrumbs, replay, or trace\n` +
1125
+ `- \`search_issue_events(organizationSlug, issueId='${sentryContent.issueId}')\` — recent events for this issue\n` +
1126
+ `- \`get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key)\` — filter by tag (environment, user, browser, …)\n`;
1115
1127
  fs.writeFileSync(sentryFilePath, md, 'utf-8');
1116
1128
  workspaceService.createTask(workspace.id, {
1117
1129
  title: `Fix: ${sentryContent.title || sentryContent.issueId || `Sentry #${sentryContent.issueNumericId}`}`,
@@ -1232,9 +1244,9 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
1232
1244
  `5. Confirm the test passes, run related tests\n` +
1233
1245
  `6. Commit referencing the Sentry Short-ID (e.g. "fix(scope): description (${sentryContent.issueId})") — Sentry auto-closes the issue when the commit is merged\n` +
1234
1246
  `\nIf you need more context, Sentry MCP tools are available:\n` +
1235
- `- mcp__sentry__get_sentry_resource(url, resourceType) — fetch the issue, breadcrumbs, replay or trace\n` +
1236
- `- mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
1237
- `- mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
1247
+ `- get_sentry_resource(url, resourceType) [use the Kōbō-managed Sentry namespace supplied for this session] — fetch the issue, breadcrumbs, replay or trace\n` +
1248
+ `- search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
1249
+ `- get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
1238
1250
  if (sentryTpl.trim().length > 0) {
1239
1251
  const renderedSentry = renderSentryInitialPrompt(sentryTpl, {
1240
1252
  issueId: sentryContent.issueId,
@@ -1680,6 +1692,13 @@ app.get('/auto-loop-states', (c) => {
1680
1692
  });
1681
1693
  app.route('/', autoLoopMessagesRoutes);
1682
1694
  // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
1695
+ // Admission inspection never changes deadlines, pending schedules or lifecycle state.
1696
+ app.get('/:id/automatic-admission', (c) => {
1697
+ const id = c.req.param('id');
1698
+ if (!workspaceService.getWorkspace(id))
1699
+ return c.json({ error: 'Workspace not found' }, 404);
1700
+ return c.json(autoLoopService.getAutomaticAdmissionStatus(id));
1701
+ });
1683
1702
  app.get('/:id/auto-loop', (c) => {
1684
1703
  try {
1685
1704
  const id = c.req.param('id');
@@ -2708,6 +2727,20 @@ app.patch('/:id', migrationGuard, async (c) => {
2708
2727
  if ('agent_description' in body) {
2709
2728
  return c.json({ error: 'agent_description must be set via the agent MCP tool, not via the API' }, 400);
2710
2729
  }
2730
+ if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
2731
+ return c.json({ error: 'Invalid workflowPolicy' }, 400);
2732
+ if (body.workflowPolicy !== undefined) {
2733
+ const policy = resolveWorkflowPolicy(workspace.workflowPolicy, body.workflowPolicy);
2734
+ const changed = WORKFLOW_ACTIONS.some((action) => policy[action] !== workspace.workflowPolicy[action]);
2735
+ // A live engine already received its policy. Persisting a different one cannot
2736
+ // revoke that prompt; wait for confirmed closure before accepting the change.
2737
+ if (changed && (agentManager.hasController(id) || isWorkspaceLifecycleBusy(id))) {
2738
+ return c.json({
2739
+ error: 'Stop the agent and wait for workspace operations to finish before changing workflow preferences.',
2740
+ code: 'workspace-busy',
2741
+ }, 409);
2742
+ }
2743
+ }
2711
2744
  if (body.model !== undefined && (typeof body.model !== 'string' || !body.model.trim())) {
2712
2745
  return c.json({ error: 'model must be a non-empty string' }, 400);
2713
2746
  }
@@ -2749,6 +2782,7 @@ app.patch('/:id', migrationGuard, async (c) => {
2749
2782
  }
2750
2783
  }
2751
2784
  if (body.status === undefined &&
2785
+ body.workflowPolicy === undefined &&
2752
2786
  body.model === undefined &&
2753
2787
  body.reasoningEffort === undefined &&
2754
2788
  body.agentPermissionMode === undefined &&
@@ -43,6 +43,11 @@ export const COMPACTION_STALL_TIMEOUT_MS = 10 * 60_000;
43
43
  // `inputStream.close()`/the result-drain watchdog forever. Generous window:
44
44
  // legitimate subagents can run for several minutes.
45
45
  export const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
46
+ /** After the last background subagent completes, the CLI normally resumes the
47
+ * parent on its own. Closing the input before that continuation starts makes
48
+ * every permission request of it fail with "Stream closed", so wait this long
49
+ * for a parent message before draining a stream that did not continue. */
50
+ export const BACKGROUND_CONTINUATION_GRACE_MS = 60_000;
46
51
  const MAX_PENDING_USER_MESSAGES = 20;
47
52
  function toMcpServersMap(specs) {
48
53
  if (!specs || specs.length === 0)
@@ -406,6 +411,30 @@ export function createClaudeCodeEngine() {
406
411
  clearTimeout(resultDrainTimer);
407
412
  resultDrainTimer = undefined;
408
413
  };
414
+ // Drain for a stream whose last background subagent completed without
415
+ // a parent continuation. Any parent message cancels it: that
416
+ // continuation's own `result` then closes the input.
417
+ let continuationGraceTimer;
418
+ const clearContinuationGrace = () => {
419
+ if (!continuationGraceTimer)
420
+ return;
421
+ clearTimeout(continuationGraceTimer);
422
+ continuationGraceTimer = undefined;
423
+ };
424
+ const armContinuationGrace = () => {
425
+ clearContinuationGrace();
426
+ continuationGraceTimer = setTimeout(() => {
427
+ continuationGraceTimer = undefined;
428
+ if (activeSubagentTaskIds.size > 0 ||
429
+ pendingResolvers.size > 0 ||
430
+ pendingToolCallIds.size > 0 ||
431
+ inputStream.hasUnansweredInput(completedResponses))
432
+ return;
433
+ inputStream.close();
434
+ armResultDrainWatchdog();
435
+ }, BACKGROUND_CONTINUATION_GRACE_MS);
436
+ continuationGraceTimer.unref?.();
437
+ };
409
438
  // Safety net for the "waiting on a background subagent" branch below:
410
439
  // if `activeSubagentToolCallIds` never empties (a missed/unrecognised
411
440
  // terminal notification), the workspace would otherwise stay `executing`
@@ -447,6 +476,7 @@ export function createClaudeCodeEngine() {
447
476
  // progress notifications alone do not end the between-turn wait.
448
477
  if (msg.type === 'assistant' || msg.type === 'user' || msg.type === 'stream_event') {
449
478
  waitingForBackground = false;
479
+ clearContinuationGrace();
450
480
  }
451
481
  // This SDK message proves that any drain condition armed by a
452
482
  // *previous* message is no longer current. A drain armed while
@@ -501,14 +531,15 @@ export function createClaudeCodeEngine() {
501
531
  else if (ev.kind === 'tool:result')
502
532
  pendingToolCallIds.delete(ev.toolCallId);
503
533
  }
504
- // After a settled result, the last background completion can
505
- // drain the stream without waiting for another result.
534
+ // After a settled result, the last background completion drains
535
+ // the stream only if the parent does not continue (see
536
+ // BACKGROUND_CONTINUATION_GRACE_MS): its continuation still needs
537
+ // the input for permission requests.
506
538
  if (subagentStallTimer &&
507
539
  activeSubagentTaskIds.size === 0 &&
508
540
  !inputStream.hasUnansweredInput(completedResponses)) {
509
541
  clearSubagentStallWatchdog();
510
- inputStream.close();
511
- armResultDrainWatchdog();
542
+ armContinuationGrace();
512
543
  }
513
544
  for (const ev of events) {
514
545
  if (ev.kind === 'session:started')
@@ -527,6 +558,7 @@ export function createClaudeCodeEngine() {
527
558
  reevaluateLivenessPause();
528
559
  if (msg.type === 'result') {
529
560
  waitingForBackground = false;
561
+ clearContinuationGrace();
530
562
  pendingToolCallIds.clear();
531
563
  completedResponses++;
532
564
  emitTurnCompletedIfSettled();
@@ -581,6 +613,7 @@ export function createClaudeCodeEngine() {
581
613
  // iterator has exited — clear it so a healthy run never triggers a
582
614
  // stray abort after it already ended.
583
615
  clearResultDrainWatchdog();
616
+ clearContinuationGrace();
584
617
  turnLiveness.stop();
585
618
  clearSubagentStallWatchdog();
586
619
  clearCompactionStallTimer();
@@ -54,6 +54,7 @@ export function buildCodexOptions(input) {
54
54
  const mcpServersConfig = {};
55
55
  for (const srv of input.mcpServers) {
56
56
  mcpServersConfig[srv.name] = {
57
+ enabled: true,
57
58
  command: srv.command,
58
59
  args: srv.args,
59
60
  env: srv.env,
@@ -1,5 +1,6 @@
1
1
  import { spawn as nodeSpawn } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
+ import path from 'node:path';
3
4
  const requireFn = createRequire(import.meta.url);
4
5
  export function resolveCodexBinary() {
5
6
  try {
@@ -7,8 +8,7 @@ export function resolveCodexBinary() {
7
8
  const pkg = requireFn(pkgPath);
8
9
  const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.codex;
9
10
  if (binRel) {
10
- const url = new URL(binRel, `file://${pkgPath}`);
11
- return url.pathname;
11
+ return path.resolve(path.dirname(pkgPath), binRel);
12
12
  }
13
13
  }
14
14
  catch {
@@ -0,0 +1,49 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { readClaudeMcpEntry } from '../../utils/mcp-client.js';
3
+ import { getIntegrationConfig } from '../integration-config-service.js';
4
+ import { buildNotionMcpConfig } from '../notion-service.js';
5
+ import { readSentryMcpConfig } from '../sentry-service.js';
6
+ /** Availability only: no provider process or credential-bearing response. */
7
+ export function hasIntegrationMcpConfig(integration, selectedKey) {
8
+ if (getIntegrationConfig(integration))
9
+ return true;
10
+ if (integration === 'notion' &&
11
+ (process.env.NOTION_API_TOKEN || process.env.NOTION_TOKEN || process.env.OPENAPI_MCP_HEADERS))
12
+ return true;
13
+ const key = selectedKey?.trim();
14
+ return Boolean(readClaudeMcpEntry((name) => key ? name === key : integration === 'notion' ? name === 'notion' : /sentry/i.test(name)));
15
+ }
16
+ /** Resolve at every launch/resume; never write credentials into a project file. */
17
+ export function buildIntegrationMcpServers(settings, onUnavailable) {
18
+ const servers = [];
19
+ // Fresh names avoid Codex recursively merging stale native config fields.
20
+ const launch = nanoid(12);
21
+ for (const integration of ['notion', 'sentry']) {
22
+ if (!settings[`${integration}Enabled`])
23
+ continue;
24
+ try {
25
+ const key = settings[`${integration}McpKey`];
26
+ if (!hasIntegrationMcpConfig(integration, key))
27
+ continue;
28
+ const config = integration === 'notion' ? buildNotionMcpConfig(key, false) : readSentryMcpConfig(key, false);
29
+ servers.push({ name: `kobo-${integration}-${launch}`, ...config });
30
+ }
31
+ catch {
32
+ // Optional connections cannot block ordinary missions. Never expose raw
33
+ // config/parse errors, which may contain credential material.
34
+ onUnavailable?.(integration);
35
+ }
36
+ }
37
+ return servers;
38
+ }
39
+ export function integrationMcpPrompt(servers, unavailable = []) {
40
+ if (!servers.length && !unavailable.length)
41
+ return '';
42
+ return [
43
+ '[Kōbō managed integrations]',
44
+ ...unavailable.map((name) => `${name}: Kōbō could not load this connection. Ask the user to check Settings if needed; do not silently substitute personal credentials.`),
45
+ 'For these integrations, use the MCP servers listed below, configured in Kōbō Settings. Prefer them over personal/native servers and namespaces from earlier turns. Discover their available tools; do not assume all servers implement the same tools. If unavailable, report the connection problem instead of silently switching credentials.',
46
+ ...servers.map((server) => `${server.name.startsWith('kobo-notion-') ? 'Notion' : 'Sentry'}: ${server.name}`),
47
+ 'These connections do not grant permission to modify remote data. Follow the user request and engine permissions.',
48
+ ].join('\n');
49
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { nanoid } from 'nanoid';
3
+ import { renderWorkflowPolicy } from '../../../shared/workflow-policy.js';
3
4
  import { getDb } from '../../db/index.js';
4
5
  import { assertAgentStopped } from '../../utils/agent-stop-result.js';
5
6
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
@@ -23,6 +24,7 @@ import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus, } fr
23
24
  import { resolveEngine } from './engines/registry.js';
24
25
  import { AGENT_NO_LONGER_RUNNING_TEXT, } from './engines/types.js';
25
26
  import { routeEvent } from './event-router.js';
27
+ import { buildIntegrationMcpServers, integrationMcpPrompt } from './integration-mcp.js';
26
28
  import { SessionController } from './session-controller.js';
27
29
  export class InterruptAgentError extends Error {
28
30
  code;
@@ -1371,10 +1373,12 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1371
1373
  activateSession(workspaceId, agentSessionId);
1372
1374
  captureTaskProgressBaseline(workspaceId, agentSessionId);
1373
1375
  const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
1376
+ const unavailableIntegrations = [];
1377
+ const integrationServers = buildIntegrationMcpServers(getGlobalSettings(), (name) => unavailableIntegrations.push(name));
1374
1378
  const options = {
1375
1379
  workspaceId,
1376
1380
  workingDir,
1377
- prompt: buildAgentPrompt(prompt, ws?.projectPath),
1381
+ prompt: buildAgentPrompt(`${ws ? renderWorkflowPolicy(ws.workflowPolicy) : ''}\n${integrationMcpPrompt(integrationServers, unavailableIntegrations)}\n${prompt}`, ws?.projectPath),
1378
1382
  model,
1379
1383
  effort: reasoningEffort,
1380
1384
  // Cascade: explicit caller override → workspace setting → 'bypass'.
@@ -1390,7 +1394,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1390
1394
  }
1391
1395
  })(),
1392
1396
  settings,
1393
- mcpServers: buildMcpServers(workspaceId, launch?.mcpEnv),
1397
+ mcpServers: [...buildMcpServers(workspaceId, launch?.mcpEnv), ...integrationServers],
1394
1398
  env: ws ? buildAgentEnv(ws.projectPath) : undefined,
1395
1399
  };
1396
1400
  beforeDispatch?.();
@@ -83,22 +83,41 @@ export function block(workspaceId, reason) {
83
83
  quotaBackoffService.cancel(workspaceId, 'completed');
84
84
  setRuntime(workspaceId, { state: 'blocked', reason });
85
85
  }
86
- /** Shared admission gate for all unattended starts. */
87
- export function canStartAutomatically(workspaceId) {
86
+ /** Read-only explanation of the shared admission gate; does not consume or reschedule work. */
87
+ export function getAutomaticAdmissionStatus(workspaceId) {
88
88
  const row = getRow(workspaceId);
89
- return (!!row &&
90
- !row.archived_at &&
91
- !row.worktree_purged_at &&
92
- !orchestrator.isShuttingDown() &&
93
- !isWorkspaceLifecycleBusy(workspaceId) &&
94
- !orchestrator.hasController(workspaceId) &&
95
- !['awaiting-user', 'compacting'].includes(row.status) &&
96
- // A manual workspace keeps its quota status after expiry. Its explicit
97
- // wakeup/cron may resume then; an enabled loop still belongs to quota recovery.
98
- !(row.status === 'quota' && row.auto_loop === 1) &&
99
- !quotaBackoffService.getPending(workspaceId) &&
100
- getRuntime(workspaceId).state !== 'blocked' &&
101
- hasFreeAgentSlot());
89
+ const running = orchestrator.runningAgentCount();
90
+ const configuredLimit = settingsService.getGlobalSettings().maxConcurrentAgents;
91
+ const limit = typeof configuredLimit === 'number' && configuredLimit > 0 ? configuredLimit : 0;
92
+ let reason = null;
93
+ if (!row)
94
+ reason = 'not-found';
95
+ else if (row.archived_at)
96
+ reason = 'archived';
97
+ else if (row.worktree_purged_at)
98
+ reason = 'purged';
99
+ else if (orchestrator.isShuttingDown())
100
+ reason = 'shutdown';
101
+ else if (isWorkspaceLifecycleBusy(workspaceId))
102
+ reason = 'lifecycle';
103
+ else if (orchestrator.hasController(workspaceId))
104
+ reason = 'active-session';
105
+ else if (row.status === 'awaiting-user')
106
+ reason = 'awaiting-user';
107
+ else if (row.status === 'compacting')
108
+ reason = 'compacting';
109
+ // Manual schedules can resume an expired quota; active loops remain owned by quota recovery.
110
+ else if ((row.status === 'quota' && row.auto_loop === 1) || quotaBackoffService.getPending(workspaceId))
111
+ reason = 'quota';
112
+ else if (getRuntime(workspaceId).state === 'blocked')
113
+ reason = 'blocked';
114
+ else if (limit > 0 && running >= limit)
115
+ reason = 'capacity';
116
+ return { allowed: reason === null, reason, running, limit };
117
+ }
118
+ /** Shared admission gate for all unattended starts. Manual launches remain exempt. */
119
+ export function canStartAutomatically(workspaceId) {
120
+ return getAutomaticAdmissionStatus(workspaceId).allowed;
102
121
  }
103
122
  /**
104
123
  * Enable auto-loop for the workspace. Spawns immediately if idle + pending
@@ -375,9 +394,9 @@ function ensureFinalVerification(workspaceId) {
375
394
  * `orchestrator.startAgent` throws during engine.start — caught below.
376
395
  */
377
396
  /**
378
- * Whether another unattended session may start right now. `0` — the default —
379
- * means no limit. Only the auto-loop consults this: a session the user starts
380
- * by hand always goes through.
397
+ * Whether another unattended session may start right now. Fresh installs use
398
+ * a limit of 2; an explicit 0 means unlimited. Auto-loop, cron and wakeup
399
+ * launches share admission, while manual launches remain exempt.
381
400
  */
382
401
  function hasFreeAgentSlot() {
383
402
  const limit = settingsService.getGlobalSettings().maxConcurrentAgents;
@@ -501,13 +520,14 @@ ${prompt}`;
501
520
  : undefined;
502
521
  const worktreePath = row.worktree_path ??
503
522
  resolveWorkspaceWorktreePath(row.project_path, row.working_branch, globalSettings.worktreesPath, projectSlug);
504
- // Plan mode would deadlock the loop (blocks MCP + edits) — promote to bypass.
505
- // Other modes (bypass/strict/interactive) are honored.
506
- const stored = (row.agent_permission_mode ?? 'bypass');
507
- const agentPermissionMode = stored === 'plan' ? 'bypass' : stored;
508
- if (stored === 'plan') {
509
- console.warn(`[auto-loop-service] Promoting plan → bypass for workspace ${workspaceId} — auto-loop cannot run in plan mode`);
510
- emitEphemeral(workspaceId, 'autoloop:permission-overridden', { from: 'plan', to: 'bypass' });
523
+ const storedMode = row.agent_permission_mode;
524
+ const agentPermissionMode = storedMode === 'bypass' || storedMode === 'strict' || storedMode === 'interactive' ? storedMode : 'plan';
525
+ if (!grooming && agentPermissionMode === 'plan') {
526
+ const message = 'Execution requires an explicit permission choice. Change this workspace from Plan to an execution mode (strict, interactive where supported, or bypass), then resume auto-loop. Permissions were not changed.';
527
+ block(workspaceId, message);
528
+ if (opts.throwOnStartAgentError)
529
+ throw new Error(message);
530
+ return;
511
531
  }
512
532
  // Pre-check: if the worktree directory is gone (user `rm -rf`-ed it),
513
533
  // fail loudly rather than letting startAgent throw a deep engine error.