@loicngr/kobo 1.11.39 → 1.11.40

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 (145) hide show
  1. package/AGENTS.md +12 -0
  2. package/CHANGELOG.md +4 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
  4. package/dist/mcp-server/kobo-tasks-server.js +13 -2
  5. package/dist/mcp-server/workspace-dialogue-client.js +28 -0
  6. package/dist/server/db/mcp-message-schema.js +16 -0
  7. package/dist/server/db/migrations.js +4 -0
  8. package/dist/server/db/schema.js +4 -0
  9. package/dist/server/db/search-schema.js +53 -0
  10. package/dist/server/index.js +17 -86
  11. package/dist/server/middleware/network-auth-middleware.js +5 -1
  12. package/dist/server/routes/changelog.js +4 -49
  13. package/dist/server/routes/git.js +2 -2
  14. package/dist/server/routes/mcp.js +47 -0
  15. package/dist/server/routes/pull-requests.js +1 -1
  16. package/dist/server/routes/search.js +13 -4
  17. package/dist/server/routes/settings.js +5 -0
  18. package/dist/server/routes/workspaces.js +225 -241
  19. package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
  20. package/dist/server/services/agent/engines/codex/engine.js +37 -8
  21. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  22. package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
  23. package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
  24. package/dist/server/services/agent/orchestrator.js +20 -3
  25. package/dist/server/services/change-source-branch-service.js +10 -42
  26. package/dist/server/services/chat-receipt-service.js +33 -0
  27. package/dist/server/services/cron-service.js +27 -1
  28. package/dist/server/services/mcp-client-context.js +12 -0
  29. package/dist/server/services/mcp-connection-info-service.js +63 -0
  30. package/dist/server/services/mcp-message-delivery-service.js +59 -0
  31. package/dist/server/services/mcp-message-request-service.js +64 -0
  32. package/dist/server/services/pr-checkout-service.js +10 -10
  33. package/dist/server/services/pr-watcher-service.js +14 -9
  34. package/dist/server/services/search/indexer.js +232 -0
  35. package/dist/server/services/search/worker.js +76 -0
  36. package/dist/server/services/search-service.js +104 -110
  37. package/dist/server/services/update-check-service.js +119 -0
  38. package/dist/server/services/websocket-service.js +121 -35
  39. package/dist/server/services/workspace-dialogue-service.js +128 -0
  40. package/dist/server/services/workspace-message-service.js +58 -0
  41. package/dist/server/services/workspace-permission-policy-service.js +33 -3
  42. package/dist/server/services/worktree-restore-service.js +2 -2
  43. package/dist/server/services/worktree-service.js +197 -124
  44. package/dist/server/utils/bounded-process.js +137 -0
  45. package/dist/server/utils/git-ops.js +76 -128
  46. package/dist/server/utils/git-repo-lock.js +27 -13
  47. package/dist/shared/workspace-dialogue-tools.js +89 -0
  48. package/dist/shared/workspace-message-types.js +19 -0
  49. package/package.json +2 -1
  50. package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
  51. package/src/client/dist/pwa/assets/ActivityFeed-MqsC-95i.js +8 -0
  52. package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BxURWYxa.js} +1 -1
  53. package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-DjiMDAUi.js} +2 -2
  54. package/src/client/dist/pwa/assets/CreatePage-B-rhBmqv.js +2 -0
  55. package/src/client/dist/pwa/assets/DashboardPage-mA5RZf1t.js +1 -0
  56. package/src/client/dist/pwa/assets/DiffViewer-Ci7ucmy3.js +8 -0
  57. package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
  58. package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-RZTvcJkT.js} +1 -1
  59. package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-CTOc3yA0.js} +1 -1
  60. package/src/client/dist/pwa/assets/GitPanel-_kL2gMoy.js +3 -0
  61. package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-D3__hKJ5.js} +1 -1
  62. package/src/client/dist/pwa/assets/MainLayout-B3JKUm3n.js +2 -0
  63. package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-td0RAVPx.js} +1 -1
  64. package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-BkyFPV7a.js} +1 -1
  65. package/src/client/dist/pwa/assets/QSelect-WspK3KC7.js +1 -0
  66. package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-7pw7iuV0.js} +1 -1
  67. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
  68. package/src/client/dist/pwa/assets/SchedulePanel-CLNM13oo.js +1 -0
  69. package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
  70. package/src/client/dist/pwa/assets/SearchPage-CmRgIvu4.js +1 -0
  71. package/src/client/dist/pwa/assets/SettingsPage-Cj6Y_TlN.js +9 -0
  72. package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
  73. package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-CCHGEsKf.js} +1 -1
  74. package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-ijmsYk-2.js} +9 -9
  75. package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DuQFjlQF.js} +1 -1
  76. package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-C5RF0lOK.js} +1 -1
  77. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BR_XElog.js} +1 -1
  78. package/src/client/dist/pwa/assets/WorkspacePage-BGCildyP.js +4 -0
  79. package/src/client/dist/pwa/assets/{WorkspacePage-BAC6u9OP.css → WorkspacePage-CqcMweNL.css} +1 -1
  80. package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-DUqzdkbg.js} +1 -1
  81. package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-ImiSpzfI.js} +1 -1
  82. package/src/client/dist/pwa/assets/de-CaHID9mW.js +17 -0
  83. package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-2C1hn1pw.js} +1 -1
  84. package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-Bm1vVaBr.js} +1 -1
  85. package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-_D8DFYXo.js} +1 -1
  86. package/src/client/dist/pwa/assets/engineFeatures-CZ9qfBYq.js +1 -0
  87. package/src/client/dist/pwa/assets/es-ZhzlCmer.js +17 -0
  88. package/src/client/dist/pwa/assets/{expand-template-B6xAlAaM.js → expand-template-CbDNnTPP.js} +1 -1
  89. package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-pPImdFUy.js} +1 -1
  90. package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-dTX38ZsV.js} +1 -1
  91. package/src/client/dist/pwa/assets/fr-BfXw7L7J.js +17 -0
  92. package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-DGluDnAm.js} +1 -1
  93. package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-BgviTnpD.js} +1 -1
  94. package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-CV-MrunL.js} +1 -1
  95. package/src/client/dist/pwa/assets/i18n-CmXBenUp.js +1 -0
  96. package/src/client/dist/pwa/assets/index-9tiw3NjK.js +20 -0
  97. package/src/client/dist/pwa/assets/it-CHAurcTF.js +17 -0
  98. package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C2VFcHf2.js} +1 -1
  99. package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-Dg9ZhbP5.js} +1 -1
  100. package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-ImgrPYNl.js} +1 -1
  101. package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-waWZXS1T.js} +1 -1
  102. package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-BB2rtCHM.js} +2 -2
  103. package/src/client/dist/pwa/assets/network-auth-h6O0qCMA.js +1 -0
  104. package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-Dyg-FGI6.js} +1 -1
  105. package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-Dfpg92li.js} +1 -1
  106. package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-DDWmNBW1.js} +1 -1
  107. package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-XUfY_zbw.js} +1 -1
  108. package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DVLs-DBA.js} +1 -1
  109. package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-D34BqMwB.js} +1 -1
  110. package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-DwUkPy-O.js} +1 -1
  111. package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-C7-UpNXx.js} +1 -1
  112. package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
  113. package/src/client/dist/pwa/assets/use-tours-DfE-kwrX.js +2 -0
  114. package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-DwwdOEAq.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-Cx1oQhEr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-BQOiJsdx.js} +1 -1
  117. package/src/client/dist/pwa/index.html +1 -1
  118. package/src/client/dist/pwa/sw.js +1 -1
  119. package/src/mcp-server/README.md +118 -2
  120. package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
  121. package/src/mcp-server/kobo-tasks-server.ts +19 -2
  122. package/src/mcp-server/workspace-dialogue-client.ts +34 -0
  123. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
  124. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
  125. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
  126. package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
  127. package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
  128. package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
  129. package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
  130. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
  131. package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
  132. package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
  133. package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
  134. package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
  135. package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
  136. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
  137. package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
  138. package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
  139. package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
  140. package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
  141. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
  142. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
  143. package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
  144. package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
  145. package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
@@ -3,6 +3,7 @@ import { isValidSkillSuite } from '../../shared/skill-suite-prompts.js';
3
3
  import { getDb } from '../db/index.js';
4
4
  import { getBackendPort } from '../services/agent/orchestrator.js';
5
5
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '../services/initial-prompt-template-service.js';
6
+ import { getMcpConnectionInfo } from '../services/mcp-connection-info-service.js';
6
7
  import { generateToken, getLanUrls } from '../services/network-access-service.js';
7
8
  import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from '../services/review-template-service.js';
8
9
  import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT } from '../services/settings-defaults.js';
@@ -82,6 +83,10 @@ app.get('/ws-events-retention-preview', (c) => {
82
83
  return c.json({ error: message }, 500);
83
84
  }
84
85
  });
86
+ // GET /api/settings/mcp — protected connection metadata, without credentials
87
+ app.get('/mcp', (c) => {
88
+ return c.json(getMcpConnectionInfo(getBackendPort(), settingsService.getGlobalSettings(), getDb().name));
89
+ });
85
90
  // GET /api/settings/network — network access state + LAN URLs
86
91
  app.get('/network', (c) => {
87
92
  try {
@@ -1,5 +1,6 @@
1
1
  import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
3
4
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
4
5
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
5
6
  const execFileAsync = promisify(execFileCb);
@@ -59,7 +60,7 @@ function workspaceErrorStatus(err) {
59
60
  /** Hono sub-router for workspace CRUD, tasks, agents, Git and PR creation. */
60
61
  const app = new Hono();
61
62
  // These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
62
- for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change']) {
63
+ for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
63
64
  app.use(route, async (c, next) => {
64
65
  try {
65
66
  await withWorkspaceLifecycleGuard(c.req.param('id'), next);
@@ -184,6 +185,7 @@ async function rollbackFailedCreation(workspace, opts) {
184
185
  try {
185
186
  return await deleteWorkspaceWithSideEffects(workspace, {
186
187
  deleteLocalBranch: opts.deleteLocalBranch,
188
+ removeWorktree: opts.removeWorktree,
187
189
  // Nothing was ever pushed at this point in the creation flow.
188
190
  deleteRemoteBranch: false,
189
191
  });
@@ -368,6 +370,7 @@ app.post('/', migrationGuard, async (c) => {
368
370
  // Set only once WE created the worktree, which is also what created the
369
371
  // branch. A failure before that point must not delete a branch we never made.
370
372
  let createdBranch = false;
373
+ let createdWorktree = false;
371
374
  // Hoisted out of the `build-prompt` block (declared with `const` there) so
372
375
  // both rollback paths — the `start-agent` branch and the outermost catch —
373
376
  // can read it to decide whether the setup-script caveat applies.
@@ -671,190 +674,206 @@ app.post('/', migrationGuard, async (c) => {
671
674
  // worktree path so they stay aligned.
672
675
  currentStep = 'create-record';
673
676
  emitCreateProgress(creationId, currentStep);
674
- let prospectiveWorktreePath;
675
677
  let workingBranchAdjusted = false;
676
- if (useReusedWorktree) {
677
- prospectiveWorktreePath = body.worktreePath;
678
- }
679
- else {
680
- try {
681
- const resolved = resolveUniqueBranchAndPath({
682
- projectPath: body.projectPath,
683
- baseBranch: workingBranch,
684
- worktreesPath: globalSettings.worktreesPath,
685
- projectSlug,
686
- });
687
- workingBranch = resolved.workingBranch;
688
- prospectiveWorktreePath = resolved.worktreePath;
689
- workingBranchAdjusted = resolved.adjusted;
690
- }
691
- catch (err) {
692
- const message = err instanceof Error ? err.message : String(err);
693
- emitCreateFailed(creationId, 'create-record', message);
694
- return c.json({ error: message, step: 'create-record' }, 409);
678
+ // Allocate the unique branch/path and create its checkout under one lock.
679
+ // Cleanup is deliberately outside: it acquires the same repository lock.
680
+ const creation = await withGitRepoLock(body.projectPath, async () => {
681
+ let prospectiveWorktreePath;
682
+ if (useReusedWorktree) {
683
+ // Another attachment may have completed while this request waited for the lock.
684
+ const existing = getDb().prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
685
+ if (existing) {
686
+ const failure = 'This worktree is already attached to another Kōbō workspace';
687
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
688
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
689
+ }
690
+ prospectiveWorktreePath = body.worktreePath;
695
691
  }
696
- }
697
- let workspace = workspaceService.createWorkspace({
698
- name: body.name,
699
- projectPath: body.projectPath,
700
- sourceBranch: body.sourceBranch,
701
- workingBranch,
702
- notionUrl: body.notionUrl,
703
- notionPageId: body.notionPageId,
704
- sentryUrl: body.sentryUrl,
705
- prUrl: body.prUrl,
706
- worktreePath: prospectiveWorktreePath,
707
- worktreeOwned: !useReusedWorktree,
708
- model: body.model,
709
- brainstormModel: body.brainstormModel,
710
- reasoningEffort: body.reasoningEffort,
711
- agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings, body.engine ?? 'claude-code'),
712
- engine: body.engine,
713
- // Set by the engine-comparison flow, which posts here once per engine
714
- // with the same id so the resulting workspaces can find each other.
715
- comparisonId: body.comparisonId,
716
- ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
717
- });
718
- createdWorkspace = workspace;
719
- // Enable auto-loop before starting the initial brainstorming session so
720
- // its model override is available when selecting that session's model.
721
- if (body.autoLoop === true) {
722
- const notionProducedTasks = body.notionUrl !== undefined &&
723
- notionContent != null &&
724
- notionContent.todos.length > 0 &&
725
- notionContent.gherkinFeatures.length > 0;
726
- const sessionMode = body.autoLoopSessionMode === 'continuous' ? 'continuous' : 'per_task';
727
- const db = getDb();
728
- db.prepare('UPDATE workspaces SET auto_loop = 1, auto_loop_ready = ?, auto_loop_session_mode = ? WHERE id = ?').run(notionProducedTasks ? 1 : 0, sessionMode, workspace.id);
729
- workspace = workspaceService.getWorkspace(workspace.id) ?? workspace;
730
- // Emit events so the frontend refreshes autoLoopStates without F5.
731
- wsService.emitEphemeral(workspace.id, 'autoloop:enabled', {});
732
- if (notionProducedTasks) {
733
- wsService.emitEphemeral(workspace.id, 'autoloop:ready-flipped', {});
692
+ else {
693
+ try {
694
+ const resolved = resolveUniqueBranchAndPath({
695
+ projectPath: body.projectPath,
696
+ baseBranch: workingBranch,
697
+ worktreesPath: globalSettings.worktreesPath,
698
+ projectSlug,
699
+ });
700
+ workingBranch = resolved.workingBranch;
701
+ prospectiveWorktreePath = resolved.worktreePath;
702
+ workingBranchAdjusted = resolved.adjusted;
703
+ }
704
+ catch (err) {
705
+ const message = err instanceof Error ? err.message : String(err);
706
+ emitCreateFailed(creationId, 'create-record', message);
707
+ return c.json({ error: message, step: 'create-record' }, 409);
708
+ }
734
709
  }
735
- }
736
- // Auto-tag the workspace based on its creation source — `notion` when
737
- // imported from a Notion page, `sentry` when bootstrapped from a Sentry
738
- // issue URL. Pre-seeded in the global tag catalogue via migration v9.
739
- // Skip any tag the user has removed from the catalogue so we respect
740
- // their choice (they may have pruned "notion"/"sentry" on purpose).
741
- const catalogTags = new Set(globalSettings.tags ?? []);
742
- const autoTags = [];
743
- if (body.notionUrl && catalogTags.has('notion'))
744
- autoTags.push('notion');
745
- if (body.sentryUrl && catalogTags.has('sentry'))
746
- autoTags.push('sentry');
747
- if (autoTags.length > 0) {
748
- try {
749
- const tagged = workspaceService.setWorkspaceTags(workspace.id, autoTags);
750
- if (tagged)
751
- workspace = tagged;
710
+ let workspace = workspaceService.createWorkspace({
711
+ name: body.name,
712
+ projectPath: body.projectPath,
713
+ sourceBranch: body.sourceBranch,
714
+ workingBranch,
715
+ notionUrl: body.notionUrl,
716
+ notionPageId: body.notionPageId,
717
+ sentryUrl: body.sentryUrl,
718
+ prUrl: body.prUrl,
719
+ worktreePath: prospectiveWorktreePath,
720
+ worktreeOwned: !useReusedWorktree,
721
+ model: body.model,
722
+ brainstormModel: body.brainstormModel,
723
+ reasoningEffort: body.reasoningEffort,
724
+ agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings, body.engine ?? 'claude-code'),
725
+ engine: body.engine,
726
+ // Set by the engine-comparison flow, which posts here once per engine
727
+ // with the same id so the resulting workspaces can find each other.
728
+ comparisonId: body.comparisonId,
729
+ ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
730
+ });
731
+ createdWorkspace = workspace;
732
+ // Enable auto-loop before starting the initial brainstorming session so
733
+ // its model override is available when selecting that session's model.
734
+ if (body.autoLoop === true) {
735
+ const notionProducedTasks = body.notionUrl !== undefined &&
736
+ notionContent != null &&
737
+ notionContent.todos.length > 0 &&
738
+ notionContent.gherkinFeatures.length > 0;
739
+ const sessionMode = body.autoLoopSessionMode === 'continuous' ? 'continuous' : 'per_task';
740
+ const db = getDb();
741
+ db.prepare('UPDATE workspaces SET auto_loop = 1, auto_loop_ready = ?, auto_loop_session_mode = ? WHERE id = ?').run(notionProducedTasks ? 1 : 0, sessionMode, workspace.id);
742
+ workspace = workspaceService.getWorkspace(workspace.id) ?? workspace;
743
+ // Emit events so the frontend refreshes autoLoopStates without F5.
744
+ wsService.emitEphemeral(workspace.id, 'autoloop:enabled', {});
745
+ if (notionProducedTasks) {
746
+ wsService.emitEphemeral(workspace.id, 'autoloop:ready-flipped', {});
747
+ }
752
748
  }
753
- catch (err) {
754
- console.error('[workspaces] Failed to apply auto tags:', err);
749
+ // Auto-tag the workspace based on its creation source — `notion` when
750
+ // imported from a Notion page, `sentry` when bootstrapped from a Sentry
751
+ // issue URL. Pre-seeded in the global tag catalogue via migration v9.
752
+ // Skip any tag the user has removed from the catalogue so we respect
753
+ // their choice (they may have pruned "notion"/"sentry" on purpose).
754
+ const catalogTags = new Set(globalSettings.tags ?? []);
755
+ const autoTags = [];
756
+ if (body.notionUrl && catalogTags.has('notion'))
757
+ autoTags.push('notion');
758
+ if (body.sentryUrl && catalogTags.has('sentry'))
759
+ autoTags.push('sentry');
760
+ if (autoTags.length > 0) {
761
+ try {
762
+ const tagged = workspaceService.setWorkspaceTags(workspace.id, autoTags);
763
+ if (tagged)
764
+ workspace = tagged;
765
+ }
766
+ catch (err) {
767
+ console.error('[workspaces] Failed to apply auto tags:', err);
768
+ }
755
769
  }
756
- }
757
- // Update workspace name with Sentry issue title if the user did not provide
758
- // a custom name and Notion hasn't already filled it. Prefix with the Sentry
759
- // short-id (e.g. "SEKUR-IOS-9 | TypeError: …") so the workspace stays
760
- // identifiable in the sidebar without opening the panel.
761
- if (sentryContent?.title && !notionContent?.title) {
762
- const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
763
- const renamed = resolveExtractedName(workspace.name, `${prefix}${sentryContent.title}`);
764
- if (renamed)
765
- workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
766
- }
767
- currentStep = 'create-tasks';
768
- emitCreateProgress(creationId, currentStep);
769
- // Create tasks from extracted Notion data
770
- if (notionContent) {
771
- let sortOrder = 0;
772
- for (const todo of notionContent.todos) {
773
- workspaceService.createTask(workspace.id, {
774
- title: todo.title,
775
- isAcceptanceCriterion: false,
776
- sortOrder: sortOrder++,
777
- });
770
+ // Update workspace name with Sentry issue title if the user did not provide
771
+ // a custom name and Notion hasn't already filled it. Prefix with the Sentry
772
+ // short-id (e.g. "SEKUR-IOS-9 | TypeError: …") so the workspace stays
773
+ // identifiable in the sidebar without opening the panel.
774
+ if (sentryContent?.title && !notionContent?.title) {
775
+ const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
776
+ const renamed = resolveExtractedName(workspace.name, `${prefix}${sentryContent.title}`);
777
+ if (renamed)
778
+ workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
778
779
  }
779
- for (const feature of notionContent.gherkinFeatures) {
780
- workspaceService.createTask(workspace.id, {
781
- title: feature,
782
- isAcceptanceCriterion: true,
783
- sortOrder: sortOrder++,
784
- });
780
+ currentStep = 'create-tasks';
781
+ emitCreateProgress(creationId, currentStep);
782
+ // Create tasks from extracted Notion data
783
+ if (notionContent) {
784
+ let sortOrder = 0;
785
+ for (const todo of notionContent.todos) {
786
+ workspaceService.createTask(workspace.id, {
787
+ title: todo.title,
788
+ isAcceptanceCriterion: false,
789
+ sortOrder: sortOrder++,
790
+ });
791
+ }
792
+ for (const feature of notionContent.gherkinFeatures) {
793
+ workspaceService.createTask(workspace.id, {
794
+ title: feature,
795
+ isAcceptanceCriterion: true,
796
+ sortOrder: sortOrder++,
797
+ });
798
+ }
799
+ // Update workspace name with Notion page title only if user didn't
800
+ // provide a custom name. Prefix with the Notion unique-id (e.g.
801
+ // Use the Notion page title when the user left the generic placeholder.
802
+ if (notionContent.title) {
803
+ const renamed = resolveExtractedName(workspace.name, notionContent.title);
804
+ if (renamed)
805
+ workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
806
+ }
785
807
  }
786
- // Update workspace name with Notion page title only if user didn't
787
- // provide a custom name. Prefix with the Notion unique-id (e.g.
788
- // Use the Notion page title when the user left the generic placeholder.
789
- if (notionContent.title) {
790
- const renamed = resolveExtractedName(workspace.name, notionContent.title);
808
+ // Update workspace name with the PR/MR title if the user did not provide
809
+ // a custom name and neither Notion nor Sentry already claimed it. This
810
+ // makes the PR a THIRD context source, lowest priority of the three
811
+ // (Notion > Sentry > PR), consistent with the checks above.
812
+ if (prContent && !notionContent?.title && !sentryContent?.title) {
813
+ const renamed = resolveExtractedName(workspace.name, prContent.title);
791
814
  if (renamed)
792
815
  workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
793
816
  }
794
- }
795
- // Update workspace name with the PR/MR title if the user did not provide
796
- // a custom name and neither Notion nor Sentry already claimed it. This
797
- // makes the PR a THIRD context source, lowest priority of the three
798
- // (Notion > Sentry > PR), consistent with the checks above.
799
- if (prContent && !notionContent?.title && !sentryContent?.title) {
800
- const renamed = resolveExtractedName(workspace.name, prContent.title);
801
- if (renamed)
802
- workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
803
- }
804
- // Create manual tasks/criteria if no Notion content was extracted
805
- if (!notionContent && (Array.isArray(body.tasks) || Array.isArray(body.acceptanceCriteria))) {
806
- let sortOrder = 0;
807
- if (Array.isArray(body.tasks)) {
808
- for (const title of body.tasks) {
809
- if (typeof title === 'string' && title.trim()) {
810
- workspaceService.createTask(workspace.id, {
811
- title: title.trim(),
812
- isAcceptanceCriterion: false,
813
- sortOrder: sortOrder++,
814
- });
817
+ // Create manual tasks/criteria if no Notion content was extracted
818
+ if (!notionContent && (Array.isArray(body.tasks) || Array.isArray(body.acceptanceCriteria))) {
819
+ let sortOrder = 0;
820
+ if (Array.isArray(body.tasks)) {
821
+ for (const title of body.tasks) {
822
+ if (typeof title === 'string' && title.trim()) {
823
+ workspaceService.createTask(workspace.id, {
824
+ title: title.trim(),
825
+ isAcceptanceCriterion: false,
826
+ sortOrder: sortOrder++,
827
+ });
828
+ }
815
829
  }
816
830
  }
817
- }
818
- if (Array.isArray(body.acceptanceCriteria)) {
819
- for (const title of body.acceptanceCriteria) {
820
- if (typeof title === 'string' && title.trim()) {
821
- workspaceService.createTask(workspace.id, {
822
- title: title.trim(),
823
- isAcceptanceCriterion: true,
824
- sortOrder: sortOrder++,
825
- });
831
+ if (Array.isArray(body.acceptanceCriteria)) {
832
+ for (const title of body.acceptanceCriteria) {
833
+ if (typeof title === 'string' && title.trim()) {
834
+ workspaceService.createTask(workspace.id, {
835
+ title: title.trim(),
836
+ isAcceptanceCriterion: true,
837
+ sortOrder: sortOrder++,
838
+ });
839
+ }
826
840
  }
827
841
  }
828
842
  }
829
- }
830
- currentStep = 'create-worktree';
831
- emitCreateProgress(creationId, currentStep);
832
- // Create git worktree for the working branch — unless we're reusing an
833
- // existing one, in which case the path is taken straight from the body.
834
- let worktreePath;
835
- if (useReusedWorktree) {
836
- worktreePath = body.worktreePath;
837
- }
838
- else {
839
- try {
840
- const created = worktreeService.createWorktree(body.projectPath, workingBranch, baseRef, globalSettings.worktreesPath, projectSlug);
841
- worktreePath = created.worktreePath;
842
- // Not an assumption: `createWorktree` reports which git command it ran.
843
- // It falls back to attaching an existing branch, and deleting that one
844
- // on rollback would destroy commits the user made before Kobo existed.
845
- createdBranch = created.branchCreated;
843
+ currentStep = 'create-worktree';
844
+ emitCreateProgress(creationId, currentStep);
845
+ // Create git worktree for the working branch — unless we're reusing an
846
+ // existing one, in which case the path is taken straight from the body.
847
+ let worktreePath;
848
+ if (useReusedWorktree) {
849
+ worktreePath = body.worktreePath;
846
850
  }
847
- catch (err) {
848
- const message = err instanceof Error ? err.message : String(err);
849
- // Roll back the half-created workspace through the SHARED demolition
850
- // path — a second inline cleanup here would drift from it. The branch
851
- // is not deleted: `createWorktree` failed, so we never created one.
852
- const rollbackWarnings = await rollbackFailedCreation(workspace, { deleteLocalBranch: false });
853
- const failure = `Failed to create worktree: ${message}`;
854
- emitCreateFailed(creationId, 'create-worktree', failure);
855
- return c.json({ error: failure, step: 'create-worktree', rollback: { done: true, warnings: rollbackWarnings } }, 500);
851
+ else {
852
+ try {
853
+ const created = await worktreeService.createWorktreeUnlocked(body.projectPath, workingBranch, baseRef, globalSettings.worktreesPath, projectSlug);
854
+ worktreePath = created.worktreePath;
855
+ // Not an assumption: `createWorktree` reports which git command it ran.
856
+ // It falls back to attaching an existing branch, and deleting that one
857
+ // on rollback would destroy commits the user made before Kobo existed.
858
+ createdBranch = created.branchCreated;
859
+ createdWorktree = true;
860
+ }
861
+ catch (err) {
862
+ if (err instanceof worktreeService.WorktreeCreationError) {
863
+ createdWorktree = true;
864
+ createdBranch = err.branchCreated;
865
+ }
866
+ throw new Error(`Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`, {
867
+ cause: err,
868
+ });
869
+ }
856
870
  }
857
- }
871
+ return { workspace, worktreePath };
872
+ });
873
+ if (creation instanceof Response)
874
+ return creation;
875
+ const { workspace } = creation;
876
+ const { worktreePath } = creation;
858
877
  currentStep = 'write-conventions';
859
878
  emitCreateProgress(creationId, currentStep);
860
879
  // Ensure Kobo-generated files are gitignored. Check both the root
@@ -1322,12 +1341,18 @@ Once the brainstorming + planning steps above are complete and you have a saved
1322
1341
  // already guards the worktree via `worktreeOwned`; the branch flag is
1323
1342
  // ours to set.
1324
1343
  const rollbackWarnings = await rollbackFailedCreation(workspace, {
1325
- deleteLocalBranch: !useReusedWorktree,
1344
+ deleteLocalBranch: createdBranch,
1345
+ removeWorktree: createdWorktree,
1326
1346
  });
1327
1347
  // The setup script has already run by this point — see the caveat's own
1328
1348
  // comment for what that rollback cannot reach.
1329
1349
  const setupCaveat = setupScriptConfigured ? SETUP_SCRIPT_ROLLBACK_CAVEAT : '';
1330
- const failure = `Failed to start the agent: ${agentStartError}. The workspace, its worktree and its branch were removed.${setupCaveat}`;
1350
+ const removed = createdBranch
1351
+ ? 'The workspace, its worktree and its branch were removed.'
1352
+ : createdWorktree
1353
+ ? 'The workspace and its worktree were removed.'
1354
+ : 'The half-created workspace was removed.';
1355
+ const failure = `Failed to start the agent: ${agentStartError}. ${removed}${setupCaveat}`;
1331
1356
  emitCreateFailed(creationId, 'start-agent', failure);
1332
1357
  return c.json({ error: failure, step: 'start-agent', rollback: { done: true, warnings: rollbackWarnings } }, 500);
1333
1358
  }
@@ -1372,7 +1397,10 @@ Once the brainstorming + planning steps above are complete and you have a saved
1372
1397
  // reported error.
1373
1398
  if (createdWorkspace) {
1374
1399
  emitCreateProgress(creationId, 'rollback');
1375
- const rollbackWarnings = await rollbackFailedCreation(createdWorkspace, { deleteLocalBranch: createdBranch });
1400
+ const rollbackWarnings = await rollbackFailedCreation(createdWorkspace, {
1401
+ deleteLocalBranch: createdBranch,
1402
+ removeWorktree: createdWorktree,
1403
+ });
1376
1404
  logError('workspaces', 'Rolled back a creation that failed outside any per-step handler', {
1377
1405
  workspaceId: createdWorkspace.id,
1378
1406
  step: currentStep,
@@ -1450,8 +1478,10 @@ app.get('/pr-states', (c) => {
1450
1478
  // stay before GET /:id.
1451
1479
  app.get('/info', (c) => {
1452
1480
  try {
1481
+ const workspaces = workspaceService.listWorkspaces(false);
1453
1482
  return c.json({
1454
- workspaces: workspaceService.listWorkspaces(false),
1483
+ workspaces,
1484
+ pendingInputs: Object.fromEntries(workspaces.map((workspace) => [workspace.id, agentManager.getPendingInputs(workspace.id)])),
1455
1485
  prSnapshots: getAllPrSnapshots(),
1456
1486
  gitStats: getAllGitStats(),
1457
1487
  // In-memory truth, not the `status` column: a workspace missing from
@@ -2119,12 +2149,8 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
2119
2149
  return c.json({ error: message }, workspaceErrorStatus(err));
2120
2150
  }
2121
2151
  });
2122
- // Cap how many recent rows one search may scan: the LIKE below cannot use an
2123
- // index past the workspace_id prefix, and better-sqlite3 is synchronous — an
2124
- // unbounded scan of a months-old workspace blocks every request and WS emit.
2125
- const HISTORY_SEARCH_SCAN_CAP = 20_000;
2126
- // GET /api/workspaces/:id/history-search — full-workspace persisted conversation search.
2127
- app.get('/:id/history-search', (c) => {
2152
+ // GET /api/workspaces/:id/history-search — the same logical-message index as global search.
2153
+ app.get('/:id/history-search', async (c) => {
2128
2154
  try {
2129
2155
  const id = c.req.param('id');
2130
2156
  if (!workspaceService.getWorkspace(id))
@@ -2132,71 +2158,23 @@ app.get('/:id/history-search', (c) => {
2132
2158
  const query = (c.req.query('q') ?? '').trim();
2133
2159
  if (query.length < 2)
2134
2160
  return c.json({ results: [] });
2135
- const limit = Math.min(parseInt(c.req.query('limit') ?? '30', 10) || 30, 100);
2136
- const needle = `%${query.replace(/[%_]/g, '\\$&')}%`;
2137
- const rows = getDb()
2138
- .prepare(`SELECT id, session_id, type, payload, created_at FROM (
2139
- SELECT rowid AS rid, id, session_id, type, payload, created_at FROM ws_events
2140
- WHERE workspace_id = ? ORDER BY rowid DESC LIMIT ?
2141
- )
2142
- WHERE payload LIKE ? ESCAPE '\\'
2143
- ORDER BY rid DESC LIMIT ?`)
2144
- .all(id, HISTORY_SEARCH_SCAN_CAP, needle, limit * 4);
2145
- const normalized = rows.flatMap((row) => {
2146
- const result = readableHistorySearchResult(row.type, row.payload, query);
2147
- if (!result)
2148
- return [];
2149
- return [{ eventId: row.id, sessionId: row.session_id, createdAt: row.created_at, ...result }];
2161
+ const limit = Math.max(1, Math.min(parseInt(c.req.query('limit') ?? '30', 10) || 30, 100));
2162
+ const results = await searchEvents(query, { workspaceId: id, includeArchived: true, limit }, c.req.raw.signal);
2163
+ return c.json({
2164
+ results: results.map((result) => ({
2165
+ eventId: result.eventId,
2166
+ sessionId: result.sessionId,
2167
+ createdAt: result.timestamp,
2168
+ kind: result.type === 'user:message' ? 'user' : 'agent',
2169
+ snippet: result.snippet,
2170
+ })),
2171
+ partial: getSearchIndexStatus().state !== 'ready',
2150
2172
  });
2151
- return c.json({ results: normalized.slice(0, limit) });
2152
2173
  }
2153
2174
  catch (err) {
2154
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2175
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 503);
2155
2176
  }
2156
2177
  });
2157
- function readableHistorySearchResult(type, rawPayload, query) {
2158
- let payload;
2159
- try {
2160
- payload = JSON.parse(rawPayload);
2161
- }
2162
- catch {
2163
- return null;
2164
- }
2165
- let text = null;
2166
- let kind = 'agent';
2167
- if (type === 'user:message') {
2168
- text = typeof payload.content === 'string' ? payload.content : null;
2169
- kind = 'user';
2170
- }
2171
- else if (type === 'agent:event') {
2172
- if (payload.kind === 'message:text' && typeof payload.text === 'string')
2173
- text = payload.text;
2174
- }
2175
- else if (type === 'agent:output') {
2176
- const message = payload.message;
2177
- if (Array.isArray(message?.content)) {
2178
- text = message.content
2179
- .flatMap((block) => {
2180
- if (!block || typeof block !== 'object')
2181
- return [];
2182
- const candidate = block;
2183
- return candidate.type === 'text' && typeof candidate.text === 'string' ? [candidate.text] : [];
2184
- })
2185
- .join('\n');
2186
- }
2187
- }
2188
- if (!text)
2189
- return null;
2190
- const matchIndex = text.toLowerCase().indexOf(query.toLowerCase());
2191
- if (matchIndex < 0)
2192
- return null;
2193
- const start = Math.max(0, matchIndex - 90);
2194
- const end = Math.min(text.length, matchIndex + query.length + 180);
2195
- return {
2196
- kind,
2197
- snippet: `${start > 0 ? '…' : ''}${text.slice(start, end).replace(/\s+/g, ' ').trim()}${end < text.length ? '…' : ''}`,
2198
- };
2199
- }
2200
2178
  /**
2201
2179
  * GET /api/workspaces/:id/sessions/:sessionId/summary — why a session ended.
2202
2180
  *
@@ -3061,7 +3039,7 @@ async function deleteWorkspaceWithSideEffectsUnlocked(workspace, opts) {
3061
3039
  if (workspace.worktreePurgedAt) {
3062
3040
  console.log(`[workspaces] skipping worktree removal on delete (already purged): ${worktreePath}`);
3063
3041
  }
3064
- else if (workspace.worktreeOwned) {
3042
+ else if (workspace.worktreeOwned && opts.removeWorktree !== false) {
3065
3043
  try {
3066
3044
  await worktreeService.removeWorktree(workspace.projectPath, worktreePath);
3067
3045
  }
@@ -3423,7 +3401,7 @@ app.post('/:id/rollback-file', async (c) => {
3423
3401
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3424
3402
  }
3425
3403
  const body = await c.req.json().catch(() => ({}));
3426
- const filePath = typeof body?.path === 'string' ? body.path.trim() : '';
3404
+ const filePath = typeof body?.path === 'string' ? body.path : '';
3427
3405
  if (!filePath) {
3428
3406
  return c.json({ error: 'Missing or invalid `path` field' }, 400);
3429
3407
  }
@@ -3436,7 +3414,13 @@ app.post('/:id/rollback-file', async (c) => {
3436
3414
  }
3437
3415
  let target;
3438
3416
  try {
3439
- target = gitOps.rollbackFile(workspace.worktreePath, workspace.workingBranch, filePath);
3417
+ target = await withGitRepoLock(workspace.worktreePath, async () => {
3418
+ if (agentManager.getAgentStatus(id) !== null)
3419
+ throw new Error('Stop the agent before rolling back a file');
3420
+ gitOps.assertCurrentBranch(workspace.worktreePath, workspace.workingBranch);
3421
+ gitOps.assertNoIndexLock(workspace.worktreePath);
3422
+ return gitOps.rollbackFile(workspace.worktreePath, workspace.workingBranch, filePath);
3423
+ });
3440
3424
  }
3441
3425
  catch (err) {
3442
3426
  const message = err instanceof Error ? err.message : String(err);