@loicngr/kobo 1.11.45 → 2.1.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 (176) hide show
  1. package/AGENTS.md +17 -2
  2. package/CHANGELOG.md +8 -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 +23 -0
  11. package/dist/server/db/schema.js +2 -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/workspaces.js +46 -12
  19. package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
  20. package/dist/server/services/agent/engines/codex/spawn.js +2 -2
  21. package/dist/server/services/agent/integration-mcp.js +49 -0
  22. package/dist/server/services/agent/orchestrator.js +6 -2
  23. package/dist/server/services/auto-loop-service.js +45 -25
  24. package/dist/server/services/custom-sound-service.js +0 -0
  25. package/dist/server/services/environment-check-service.js +125 -0
  26. package/dist/server/services/initial-prompt-template-service.js +8 -21
  27. package/dist/server/services/integration-config-service.js +65 -0
  28. package/dist/server/services/legacy-public-prompts.js +112 -0
  29. package/dist/server/services/notion-service.js +25 -61
  30. package/dist/server/services/sentry-service.js +17 -12
  31. package/dist/server/services/settings-defaults.js +5 -0
  32. package/dist/server/services/settings-service.js +112 -23
  33. package/dist/server/services/skill-suite-prompts.js +29 -22
  34. package/dist/server/services/templates-service.js +31 -8
  35. package/dist/server/services/terminal-service.js +16 -0
  36. package/dist/server/services/wakeup-service.js +7 -5
  37. package/dist/server/services/workspace-service.js +25 -4
  38. package/dist/server/services/worktree-service.js +3 -1
  39. package/dist/server/utils/git-repo-lock.js +7 -3
  40. package/dist/server/utils/mcp-client.js +26 -12
  41. package/dist/server/utils/mcp-process.js +25 -0
  42. package/dist/server/utils/node-pty-spawn-helper.js +31 -0
  43. package/dist/server/utils/paths.js +7 -0
  44. package/dist/shared/auto-loop-prompts.js +6 -2
  45. package/dist/shared/automatic-admission.js +1 -0
  46. package/dist/shared/environment.js +1 -0
  47. package/dist/shared/notification-assets.js +73 -0
  48. package/dist/shared/skill-suite-prompts.js +6 -3
  49. package/dist/shared/workflow-policy.js +25 -0
  50. package/docs/getting-started.md +47 -0
  51. package/docs/troubleshooting.md +49 -0
  52. package/package.json +19 -7
  53. package/src/client/dist/pwa/assets/ActivityFeed-DRgetl1M.js +8 -0
  54. package/src/client/dist/pwa/assets/{ChangelogPage-CnXTnFhe.js → ChangelogPage-BJdICFvz.js} +1 -1
  55. package/src/client/dist/pwa/assets/{ComparisonPanel-CKylN45q.js → ComparisonPanel-2GLd8KIy.js} +2 -2
  56. package/src/client/dist/pwa/assets/CreatePage-B9AdNJZj.js +2 -0
  57. package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +1 -0
  58. package/src/client/dist/pwa/assets/DashboardPage-CzVOvjSP.js +1 -0
  59. package/src/client/dist/pwa/assets/DiffViewer-X25vuf83.js +8 -0
  60. package/src/client/dist/pwa/assets/{DocumentsPanel-BeCttB-x.js → DocumentsPanel-BastjrEm.js} +1 -1
  61. package/src/client/dist/pwa/assets/{DrawerToggleButton-BJBBYeOc.js → DrawerToggleButton-DbipGmBL.js} +1 -1
  62. package/src/client/dist/pwa/assets/{GitPanel-BFCnyhE2.css → GitPanel-C4wOcZV2.css} +1 -1
  63. package/src/client/dist/pwa/assets/GitPanel-CnW4iVao.js +3 -0
  64. package/src/client/dist/pwa/assets/{HealthPage-l88TEk-k.js → HealthPage-aieNQly-.js} +1 -1
  65. package/src/client/dist/pwa/assets/MainLayout-Cc4XzCAy.css +1 -0
  66. package/src/client/dist/pwa/assets/MainLayout-DqBbE8o4.js +2 -0
  67. package/src/client/dist/pwa/assets/{QBtnDropdown-44Mj-y_1.js → QBtnDropdown-BWy-rh45.js} +1 -1
  68. package/src/client/dist/pwa/assets/{QExpansionItem-Bok1m8Wi.js → QExpansionItem-DfmdBGuW.js} +1 -1
  69. package/src/client/dist/pwa/assets/QSelect-BfqaWakg.js +1 -0
  70. package/src/client/dist/pwa/assets/{QTooltip-BDihn2aY.js → QTooltip-Cy0T0vk2.js} +1 -1
  71. package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +1 -0
  72. package/src/client/dist/pwa/assets/SchedulePanel-YQPObRR3.js +1 -0
  73. package/src/client/dist/pwa/assets/{SearchPage-BwZ2TX1G.js → SearchPage-BoBjmItc.js} +1 -1
  74. package/src/client/dist/pwa/assets/SettingsPage-0GtDcXRC.js +9 -0
  75. package/src/client/dist/pwa/assets/{SettingsPage-W6N01tGz.css → SettingsPage-BYdLXJEJ.css} +1 -1
  76. package/src/client/dist/pwa/assets/SplitWorkspacePage-4wFh65Lh.js +1 -0
  77. package/src/client/dist/pwa/assets/{TerminalPanel-Cq-FR7A7.js → TerminalPanel-b70x_jqH.js} +4 -4
  78. package/src/client/dist/pwa/assets/{TouchPan-BQ1cvzRC.js → TouchPan-CcTVcxwd.js} +1 -1
  79. package/src/client/dist/pwa/assets/{TourReplayButton-Dz1yEz_z.js → TourReplayButton-CpEMnQS8.js} +1 -1
  80. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-C5lTHTp2.js +1 -0
  81. package/src/client/dist/pwa/assets/{WorkspacePage-BbUvR1Vf.css → WorkspacePage--5HsnMQ1.css} +1 -1
  82. package/src/client/dist/pwa/assets/WorkspacePage-D_V9gNON.js +4 -0
  83. package/src/client/dist/pwa/assets/{browser-BmWgBx9M.js → browser-LBbxVwWf.js} +1 -1
  84. package/src/client/dist/pwa/assets/{build-path-tree-6EjBmYYY.js → build-path-tree-DyvtWxb0.js} +1 -1
  85. package/src/client/dist/pwa/assets/custom-sounds-BpeLZUDW.js +1 -0
  86. package/src/client/dist/pwa/assets/de-v6i2HlE2.js +17 -0
  87. package/src/client/dist/pwa/assets/{documents-BZ8bwcjr.js → documents-Bjx8lz-h.js} +1 -1
  88. package/src/client/dist/pwa/assets/{editor.api-CK7--ckY.js → editor.api-BC3PbJkx.js} +1 -1
  89. package/src/client/dist/pwa/assets/efforts-BDHAnNUs.js +9 -0
  90. package/src/client/dist/pwa/assets/engineFeatures-hlEiwqt-.js +1 -0
  91. package/src/client/dist/pwa/assets/es-Bc1KgkAT.js +17 -0
  92. package/src/client/dist/pwa/assets/{expand-template-C8VyjASo.js → expand-template-DLDl1qPx.js} +1 -1
  93. package/src/client/dist/pwa/assets/{format-LtTLLitJ.js → format-Q94deO3I.js} +7 -7
  94. package/src/client/dist/pwa/assets/{formatters-Bl9zA7Ai.js → formatters-CWxRHuUu.js} +1 -1
  95. package/src/client/dist/pwa/assets/fr-ByYPK5vr.js +17 -0
  96. package/src/client/dist/pwa/assets/{freemarker2-DxezMaje.js → freemarker2-MPhk0eTh.js} +1 -1
  97. package/src/client/dist/pwa/assets/{handlebars-DK_Bj6Rx.js → handlebars-C60ru5gR.js} +1 -1
  98. package/src/client/dist/pwa/assets/{html-DFfsfStD.js → html-ClzchR9j.js} +1 -1
  99. package/src/client/dist/pwa/assets/i18n-Sy42tm-V.js +1 -0
  100. package/src/client/dist/pwa/assets/index-CqZ7sDcM.js +20 -0
  101. package/src/client/dist/pwa/assets/it-BYF_88z6.js +17 -0
  102. package/src/client/dist/pwa/assets/{javascript-DuNbcaL7.js → javascript-C43UJmMx.js} +1 -1
  103. package/src/client/dist/pwa/assets/{liquid-CM3JKyhv.js → liquid-CYTrvTKF.js} +1 -1
  104. package/src/client/dist/pwa/assets/{mdx-BG3zYwNF.js → mdx-CxsXOvkQ.js} +1 -1
  105. package/src/client/dist/pwa/assets/{monaco-contributions-DLscZGH-.js → monaco-contributions-Cl4-iVpK.js} +1 -1
  106. package/src/client/dist/pwa/assets/{monaco.contribution-DnhZHwXT.js → monaco.contribution-DkjLUmyA.js} +2 -2
  107. package/src/client/dist/pwa/assets/network-auth-CXQn2wO7.js +1 -0
  108. package/src/client/dist/pwa/assets/notify-theme-BrueBr2j.js +1 -0
  109. package/src/client/dist/pwa/assets/{pending-diff-open-dYIFdS4r.js → pending-diff-open-B4POItbR.js} +1 -1
  110. package/src/client/dist/pwa/assets/permissionModes-B-ciuUbz.js +36 -0
  111. package/src/client/dist/pwa/assets/{project-color-R_1je4vW.js → project-color-CM3hflLH.js} +1 -1
  112. package/src/client/dist/pwa/assets/{python-COs6pHk2.js → python-s2IMpsoV.js} +1 -1
  113. package/src/client/dist/pwa/assets/{razor-Jl1VqMsk.js → razor-DVNXiVBh.js} +1 -1
  114. package/src/client/dist/pwa/assets/render-chat-markdown-TU6CDfD8.js +69 -0
  115. package/src/client/dist/pwa/assets/{typescript-BU_TUuAM.js → typescript-BYT_8ABE.js} +1 -1
  116. package/src/client/dist/pwa/assets/use-tours-CNe8qb1q.js +2 -0
  117. package/src/client/dist/pwa/assets/workspace-templates-tBxXE52A.js +1 -0
  118. package/src/client/dist/pwa/assets/{xml-gFFsobxE.js → xml-BIG-fmxk.js} +1 -1
  119. package/src/client/dist/pwa/assets/{yaml-DaXaqaGD.js → yaml-D7gu2CQh.js} +1 -1
  120. package/src/client/dist/pwa/index.html +1 -1
  121. package/src/client/dist/pwa/sounds/neutral.wav +0 -0
  122. package/src/client/dist/pwa/sounds/ready.wav +0 -0
  123. package/src/client/dist/pwa/sw.js +1 -1
  124. package/src/client/dist/pwa/assets/ActivityFeed-CLXjdqS8.js +0 -8
  125. package/src/client/dist/pwa/assets/CreatePage-SoCcvxuD.js +0 -2
  126. package/src/client/dist/pwa/assets/CreatePage-TY9g3hZb.css +0 -1
  127. package/src/client/dist/pwa/assets/DashboardPage-CZQLfoV1.js +0 -1
  128. package/src/client/dist/pwa/assets/DiffViewer-Csf3WmLc.js +0 -8
  129. package/src/client/dist/pwa/assets/GitPanel-B-ZHgghQ.js +0 -3
  130. package/src/client/dist/pwa/assets/MainLayout-BAAQLq95.css +0 -1
  131. package/src/client/dist/pwa/assets/MainLayout-Cf4Q9x1u.js +0 -2
  132. package/src/client/dist/pwa/assets/QDrawer-B4qHw8wt.js +0 -1
  133. package/src/client/dist/pwa/assets/QSelect-BbtD5Iks.js +0 -1
  134. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +0 -1
  135. package/src/client/dist/pwa/assets/SchedulePanel-DiWCDNZ8.js +0 -1
  136. package/src/client/dist/pwa/assets/SettingsPage-BUh0WfNf.js +0 -9
  137. package/src/client/dist/pwa/assets/SplitWorkspacePage-Bg6nKBcA.js +0 -1
  138. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-BCVOP8vp.js +0 -1
  139. package/src/client/dist/pwa/assets/WorkspacePage-Cl8gap4b.js +0 -4
  140. package/src/client/dist/pwa/assets/de-COObokpu.js +0 -17
  141. package/src/client/dist/pwa/assets/efforts-Bw0o2Bud.js +0 -9
  142. package/src/client/dist/pwa/assets/engineFeatures-DQO9HHXI.js +0 -1
  143. package/src/client/dist/pwa/assets/es-By2Q9Tca.js +0 -17
  144. package/src/client/dist/pwa/assets/fouet-ahh-DdxrUXRj.mp3 +0 -0
  145. package/src/client/dist/pwa/assets/fr-D-sd0ddn.js +0 -17
  146. package/src/client/dist/pwa/assets/i18n-aDWy4KHt.js +0 -1
  147. package/src/client/dist/pwa/assets/index-BLd8DAzg.js +0 -20
  148. package/src/client/dist/pwa/assets/it-CeK5lyIA.js +0 -17
  149. package/src/client/dist/pwa/assets/network-auth-BJiPbO5l.js +0 -1
  150. package/src/client/dist/pwa/assets/notify-theme-D4p3qPkp.js +0 -1
  151. package/src/client/dist/pwa/assets/permissionModes-B7fuUegU.js +0 -36
  152. package/src/client/dist/pwa/assets/render-chat-markdown-AaOo0Tgq.js +0 -67
  153. package/src/client/dist/pwa/assets/use-tours-CVn874kD.js +0 -2
  154. package/src/client/dist/pwa/assets/workspace-templates-BCKR8TXr.js +0 -1
  155. package/src/client/dist/pwa/sounds/7eme-compagnie-03.mp3 +0 -0
  156. package/src/client/dist/pwa/sounds/aller-ftg.mp3 +0 -0
  157. package/src/client/dist/pwa/sounds/arrete-de-mentir.mp3 +0 -0
  158. package/src/client/dist/pwa/sounds/arretez-les-messages.mp3 +0 -0
  159. package/src/client/dist/pwa/sounds/bah-alors-on-est-nul.mp3 +0 -0
  160. package/src/client/dist/pwa/sounds/basic-notification.mp3 +0 -0
  161. package/src/client/dist/pwa/sounds/ca_va_peter.mp3 +0 -0
  162. package/src/client/dist/pwa/sounds/dry-fart.mp3 +0 -0
  163. package/src/client/dist/pwa/sounds/faaah.mp3 +0 -0
  164. package/src/client/dist/pwa/sounds/for-shure.mp3 +0 -0
  165. package/src/client/dist/pwa/sounds/gta-v-death.mp3 +0 -0
  166. package/src/client/dist/pwa/sounds/hey.mp3 +0 -0
  167. package/src/client/dist/pwa/sounds/mais-laisse-moi-dormir-zebi.mp3 +0 -0
  168. package/src/client/dist/pwa/sounds/nan-tu-degages.mp3 +0 -0
  169. package/src/client/dist/pwa/sounds/nan-wallah-pardon.mp3 +0 -0
  170. package/src/client/dist/pwa/sounds/ouais-cest-greg.mp3 +0 -0
  171. package/src/client/dist/pwa/sounds/pas-ca-zinedine.mp3 +0 -0
  172. package/src/client/dist/pwa/sounds/ta-gueule.mp3 +0 -0
  173. package/src/client/dist/pwa/sounds/travail_termine.mp3 +0 -0
  174. package/src/client/dist/pwa/sounds/tu-vas-la-fermer.mp3 +0 -0
  175. package/src/client/dist/pwa/sounds/warcraft-3-humain-travail.mp3 +0 -0
  176. package/src/client/dist/pwa/sounds/yaaa.mp3 +0 -0
@@ -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,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 &&
@@ -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.
@@ -0,0 +1,125 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { access, stat } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { getKoboHome } from '../utils/paths.js';
8
+ import { resolveWorktreesRoot } from '../utils/worktree-paths.js';
9
+ import { detectPlatform, resolveClaudeBinaryPath } from './agent/engines/claude-code/resolve-binary.js';
10
+ import { resolveCodexBinary } from './agent/engines/codex/spawn.js';
11
+ import { getGlobalSettings } from './settings-service.js';
12
+ const runFile = promisify(execFile);
13
+ const require = createRequire(import.meta.url);
14
+ /** Probe the nearest existing parent without creating directories or test files. */
15
+ async function writableDirectory(directory, allowMissing = false) {
16
+ let current = path.resolve(directory);
17
+ for (;;) {
18
+ try {
19
+ const info = await stat(current);
20
+ if (!info.isDirectory())
21
+ return false;
22
+ await access(current, constants.W_OK | constants.X_OK);
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ if (!allowMissing || error.code !== 'ENOENT')
27
+ return false;
28
+ const parent = path.dirname(current);
29
+ if (parent === current)
30
+ return false;
31
+ current = parent;
32
+ }
33
+ }
34
+ }
35
+ function liveProbe() {
36
+ return {
37
+ platform: process.platform,
38
+ nodeVersion: process.versions.node,
39
+ dataDirectory: getKoboHome(),
40
+ run: async (command, args) => {
41
+ const { stdout } = await runFile(command, args, {
42
+ timeout: 3_000,
43
+ killSignal: 'SIGKILL',
44
+ maxBuffer: 16_384,
45
+ encoding: 'utf8',
46
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
47
+ });
48
+ return stdout.trim();
49
+ },
50
+ writableDirectory,
51
+ claudeBinary: () => {
52
+ const binary = resolveClaudeBinaryPath();
53
+ if (binary)
54
+ return binary;
55
+ const platform = detectPlatform();
56
+ try {
57
+ if (platform.platform === 'darwin')
58
+ return require.resolve(`@anthropic-ai/claude-agent-sdk-darwin-${platform.arch}/claude`);
59
+ if (platform.platform === 'linux' && !platform.isGlibc)
60
+ return require.resolve(`@anthropic-ai/claude-agent-sdk-linux-${platform.arch}-musl/claude`);
61
+ }
62
+ catch {
63
+ /* The SDK may support another layout: do not claim it is executable. */
64
+ }
65
+ return undefined;
66
+ },
67
+ worktreesDirectory: (project) => resolveWorktreesRoot(project, getGlobalSettings().worktreesPath),
68
+ codexBinary: resolveCodexBinary,
69
+ };
70
+ }
71
+ /** No credential files, provider calls, generated prompts, or raw process errors in this report. */
72
+ export async function checkEnvironment(request, probe = liveProbe()) {
73
+ const [major, minor] = probe.nodeVersion.split('.').map(Number);
74
+ const checks = [
75
+ { code: 'node', status: major > 24 || (major === 24 && minor >= 15) ? 'ok' : 'error' },
76
+ { code: 'platform', status: ['linux', 'darwin'].includes(probe.platform) ? 'ok' : 'error' },
77
+ ];
78
+ const commandAvailable = async (command, args) => {
79
+ try {
80
+ await probe.run(command, args);
81
+ return 'ok';
82
+ }
83
+ catch (error) {
84
+ return error.code === 'ENOENT' ? 'missing' : 'error';
85
+ }
86
+ };
87
+ const binary = request.engine === 'codex' ? probe.codexBinary() : probe.claudeBinary();
88
+ const [git, shell, storage, runtime] = await Promise.all([
89
+ commandAvailable('git', ['--version']),
90
+ commandAvailable('bash', ['--version']),
91
+ probe.writableDirectory(probe.dataDirectory, true),
92
+ binary ? commandAvailable(binary, ['--version']) : Promise.resolve('unknown'),
93
+ ]);
94
+ checks.push({ code: 'git', status: git }, { code: 'shell', status: shell }, { code: 'storage', status: storage ? 'ok' : 'error' }, { code: 'runtime', status: runtime }, { code: 'authentication', status: 'unknown' }, { code: 'model', status: 'unknown' });
95
+ if (request.projectPath) {
96
+ const args = ['-C', path.resolve(request.projectPath)];
97
+ let repository = false;
98
+ try {
99
+ repository = (await probe.run('git', [...args, 'rev-parse', '--is-inside-work-tree'])) === 'true';
100
+ }
101
+ catch {
102
+ // No stderr is returned: it may contain a private path or remote URL.
103
+ }
104
+ const [commit, writable, worktrees] = await Promise.all([
105
+ repository ? commandAvailable('git', [...args, 'rev-parse', '--verify', 'HEAD']) : false,
106
+ probe.writableDirectory(request.projectPath),
107
+ probe.writableDirectory(probe.worktreesDirectory(request.projectPath), true),
108
+ ]);
109
+ checks.push({ code: 'repository', status: repository ? 'ok' : 'error' }, { code: 'firstCommit', status: commit === 'ok' ? 'ok' : 'missing' }, { code: 'projectWritable', status: writable ? 'ok' : 'error' }, { code: 'worktrees', status: worktrees ? 'ok' : 'error' });
110
+ }
111
+ return { engine: request.engine, checkedAt: new Date().toISOString(), checks };
112
+ }
113
+ const pending = new Map();
114
+ /** Coalesce tabs checking the same target; do not retain private paths after completion. */
115
+ export function getEnvironmentReport(request) {
116
+ const key = JSON.stringify(request);
117
+ const existing = pending.get(key);
118
+ if (existing)
119
+ return existing;
120
+ if (pending.size >= 8)
121
+ return Promise.reject(new Error('ENVIRONMENT_BUSY'));
122
+ const result = checkEnvironment(request).finally(() => pending.delete(key));
123
+ pending.set(key, result);
124
+ return result;
125
+ }