@loicngr/kobo 1.11.39 → 1.11.41

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 (154) hide show
  1. package/AGENTS.md +101 -32
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +6 -4
  4. package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
  5. package/dist/mcp-server/kobo-tasks-server.js +13 -2
  6. package/dist/mcp-server/workspace-dialogue-client.js +28 -0
  7. package/dist/server/db/mcp-message-schema.js +16 -0
  8. package/dist/server/db/migrations.js +4 -0
  9. package/dist/server/db/schema.js +4 -0
  10. package/dist/server/db/search-schema.js +53 -0
  11. package/dist/server/index.js +17 -86
  12. package/dist/server/middleware/network-auth-middleware.js +5 -1
  13. package/dist/server/routes/changelog.js +4 -49
  14. package/dist/server/routes/git.js +2 -2
  15. package/dist/server/routes/images.js +65 -5
  16. package/dist/server/routes/mcp.js +47 -0
  17. package/dist/server/routes/pull-requests.js +1 -1
  18. package/dist/server/routes/search.js +13 -4
  19. package/dist/server/routes/settings.js +5 -0
  20. package/dist/server/routes/workspaces.js +250 -248
  21. package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
  22. package/dist/server/services/agent/engines/codex/engine.js +37 -8
  23. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  24. package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
  25. package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
  26. package/dist/server/services/agent/orchestrator.js +20 -3
  27. package/dist/server/services/attachment-service.js +122 -0
  28. package/dist/server/services/change-source-branch-service.js +10 -42
  29. package/dist/server/services/chat-receipt-service.js +33 -0
  30. package/dist/server/services/cron-service.js +27 -1
  31. package/dist/server/services/image-service.js +16 -7
  32. package/dist/server/services/mcp-client-context.js +12 -0
  33. package/dist/server/services/mcp-connection-info-service.js +63 -0
  34. package/dist/server/services/mcp-message-delivery-service.js +59 -0
  35. package/dist/server/services/mcp-message-request-service.js +64 -0
  36. package/dist/server/services/pr-checkout-service.js +10 -10
  37. package/dist/server/services/pr-watcher-service.js +14 -9
  38. package/dist/server/services/search/indexer.js +232 -0
  39. package/dist/server/services/search/worker.js +76 -0
  40. package/dist/server/services/search-service.js +104 -110
  41. package/dist/server/services/update-check-service.js +119 -0
  42. package/dist/server/services/websocket-service.js +121 -35
  43. package/dist/server/services/workspace-dialogue-service.js +128 -0
  44. package/dist/server/services/workspace-message-service.js +58 -0
  45. package/dist/server/services/workspace-permission-policy-service.js +33 -3
  46. package/dist/server/services/worktree-restore-service.js +2 -2
  47. package/dist/server/services/worktree-service.js +197 -124
  48. package/dist/server/utils/bounded-process.js +137 -0
  49. package/dist/server/utils/git-ops.js +76 -128
  50. package/dist/server/utils/git-repo-lock.js +27 -13
  51. package/dist/shared/attachments.js +58 -0
  52. package/dist/shared/workspace-dialogue-tools.js +89 -0
  53. package/dist/shared/workspace-message-types.js +19 -0
  54. package/package.json +2 -1
  55. package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
  56. package/src/client/dist/pwa/assets/ActivityFeed-DTIe6O9o.js +8 -0
  57. package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BWeu0YeI.js} +1 -1
  58. package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-o_EHlTwV.js} +2 -2
  59. package/src/client/dist/pwa/assets/CreatePage-CGi88k6z.css +1 -0
  60. package/src/client/dist/pwa/assets/CreatePage-DbY5gBv8.js +2 -0
  61. package/src/client/dist/pwa/assets/DashboardPage-CIo_gesD.js +1 -0
  62. package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
  63. package/src/client/dist/pwa/assets/DiffViewer-DEcf48KI.js +8 -0
  64. package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-D9vcB91W.js} +1 -1
  65. package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-Ca0_CxSl.js} +1 -1
  66. package/src/client/dist/pwa/assets/GitPanel-DCmB2R5i.js +3 -0
  67. package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-_q3vy_xn.js} +1 -1
  68. package/src/client/dist/pwa/assets/MainLayout-Duwnq3cc.js +2 -0
  69. package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-BeiZIHVA.js} +1 -1
  70. package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-Fj3M9QGD.js} +1 -1
  71. package/src/client/dist/pwa/assets/QSelect-DtSUuK8t.js +1 -0
  72. package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-DwkX8eQ6.js} +1 -1
  73. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
  74. package/src/client/dist/pwa/assets/SchedulePanel-BlZDd7TK.js +1 -0
  75. package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
  76. package/src/client/dist/pwa/assets/SearchPage-BGi1yRxU.js +1 -0
  77. package/src/client/dist/pwa/assets/SettingsPage-DnO-ImWE.js +9 -0
  78. package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
  79. package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-vz6jN1DM.js} +1 -1
  80. package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-IdPIGp2_.js} +9 -9
  81. package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DTJqxVp9.js} +1 -1
  82. package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-Bj2GGyhU.js} +1 -1
  83. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BBaGZlwv.js} +1 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +1 -0
  85. package/src/client/dist/pwa/assets/WorkspacePage-Dy0spsfi.js +4 -0
  86. package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-BFj5wwbS.js} +1 -1
  87. package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-C-HSiySk.js} +1 -1
  88. package/src/client/dist/pwa/assets/de-BdLe7apJ.js +17 -0
  89. package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-C2Xnjh9Z.js} +1 -1
  90. package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-BbKfnNmv.js} +1 -1
  91. package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-BM7fdaaL.js} +1 -1
  92. package/src/client/dist/pwa/assets/engineFeatures-b74YRODT.js +1 -0
  93. package/src/client/dist/pwa/assets/es-C1LM-3aq.js +17 -0
  94. package/src/client/dist/pwa/assets/expand-template-DiDWiIkx.js +1 -0
  95. package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-CnbUxAmw.js} +1 -1
  96. package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-B2uUIbAY.js} +1 -1
  97. package/src/client/dist/pwa/assets/fr-KCK6r5ga.js +17 -0
  98. package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-Cu1vGY0x.js} +1 -1
  99. package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-CETuqLyz.js} +1 -1
  100. package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-BTNpr39P.js} +1 -1
  101. package/src/client/dist/pwa/assets/i18n-BkUirOzl.js +1 -0
  102. package/src/client/dist/pwa/assets/index-mvh5w1cM.js +20 -0
  103. package/src/client/dist/pwa/assets/it-BM6t0IGU.js +17 -0
  104. package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C7Ncb6V7.js} +1 -1
  105. package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-D0hUdg4x.js} +1 -1
  106. package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-BK5H1Xst.js} +1 -1
  107. package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-BBW6JNgz.js} +1 -1
  108. package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-1LZrAlY4.js} +2 -2
  109. package/src/client/dist/pwa/assets/network-auth-CVGS__6a.js +1 -0
  110. package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-DIKLxr8F.js} +1 -1
  111. package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-B02zuH1j.js} +1 -1
  112. package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-C3otN1Vk.js} +1 -1
  113. package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-BUcW_eZA.js} +1 -1
  114. package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DEeUVvnp.js} +1 -1
  115. package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-CG6cL8ya.js} +1 -1
  116. package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-Ct_T5TPN.js} +1 -1
  117. package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-CSQAg5UC.js} +1 -1
  118. package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
  119. package/src/client/dist/pwa/assets/use-tours-_6HwJ_Rp.js +2 -0
  120. package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-B8WWsWEI.js} +1 -1
  121. package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-BSB4-DXR.js} +1 -1
  122. package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-CSGKph85.js} +1 -1
  123. package/src/client/dist/pwa/index.html +1 -1
  124. package/src/client/dist/pwa/sw.js +1 -1
  125. package/src/mcp-server/README.md +169 -9
  126. package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
  127. package/src/mcp-server/kobo-tasks-server.ts +19 -2
  128. package/src/mcp-server/workspace-dialogue-client.ts +34 -0
  129. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
  130. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +0 -1
  131. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
  132. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
  133. package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
  134. package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
  135. package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
  136. package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
  137. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
  138. package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
  139. package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
  140. package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
  141. package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
  142. package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
  143. package/src/client/dist/pwa/assets/WorkspacePage-BAC6u9OP.css +0 -1
  144. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
  145. package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
  146. package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
  147. package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
  148. package/src/client/dist/pwa/assets/expand-template-B6xAlAaM.js +0 -1
  149. package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
  150. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
  151. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
  152. package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
  153. package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
  154. package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
@@ -1,17 +1,21 @@
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);
6
7
  import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { Hono } from 'hono';
10
+ import { bodyLimit } from 'hono/body-limit';
11
+ import { MAX_ATTACHMENT_REQUEST_BYTES } from '../../shared/attachments.js';
9
12
  import { AUTO_LOOP_HARD_RULES, buildAutoLoopGroomingSteps, buildGroomingIntro } from '../../shared/auto-loop-prompts.js';
10
13
  import { getDb } from '../db/index.js';
11
14
  import { migrationGuard } from '../middleware/migration-guard.js';
12
15
  import { listEngines } from '../services/agent/engines/registry.js';
13
16
  import * as agentManager from '../services/agent/orchestrator.js';
14
17
  import * as archiveScriptService from '../services/archive-script-service.js';
18
+ import { AttachmentRequestError, attachmentReference, readWorkspaceCreationRequest, removeAttachments, saveAttachments, } from '../services/attachment-service.js';
15
19
  import * as autoLoopService from '../services/auto-loop-service.js';
16
20
  import { changeSourceBranch } from '../services/change-source-branch-service.js';
17
21
  import { listChatHistory, pushChatHistory } from '../services/chat-history-service.js';
@@ -59,7 +63,7 @@ function workspaceErrorStatus(err) {
59
63
  /** Hono sub-router for workspace CRUD, tasks, agents, Git and PR creation. */
60
64
  const app = new Hono();
61
65
  // 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']) {
66
+ for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
63
67
  app.use(route, async (c, next) => {
64
68
  try {
65
69
  await withWorkspaceLifecycleGuard(c.req.param('id'), next);
@@ -182,11 +186,16 @@ function emitCreateFailed(creationId, step, message) {
182
186
  */
183
187
  async function rollbackFailedCreation(workspace, opts) {
184
188
  try {
185
- return await deleteWorkspaceWithSideEffects(workspace, {
189
+ const warnings = await deleteWorkspaceWithSideEffects(workspace, {
186
190
  deleteLocalBranch: opts.deleteLocalBranch,
191
+ removeWorktree: opts.removeWorktree,
187
192
  // Nothing was ever pushed at this point in the creation flow.
188
193
  deleteRemoteBranch: false,
189
194
  });
195
+ if (!opts.removeWorktree && opts.attachments?.length && fs.existsSync(workspace.worktreePath)) {
196
+ warnings.push(...(await removeAttachments(workspace.worktreePath, opts.attachments)));
197
+ }
198
+ return warnings;
190
199
  }
191
200
  catch (err) {
192
201
  const message = err instanceof Error ? err.message : String(err);
@@ -351,8 +360,11 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
351
360
  return c.json({ error: err instanceof Error ? err.message : 'Failed to switch engine' }, 500);
352
361
  }
353
362
  });
354
- // POST /api/workspaces — create workspace
355
- app.post('/', migrationGuard, async (c) => {
363
+ const creationBodyLimit = bodyLimit({
364
+ maxSize: MAX_ATTACHMENT_REQUEST_BYTES,
365
+ onError: (c) => c.json({ error: 'Workspace creation request is too large', step: 'validate' }, 413),
366
+ });
367
+ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
356
368
  // Declared outside the try so the outermost catch (any unforeseen throw,
357
369
  // including a malformed request body) can still name a step instead of
358
370
  // leaving the create-progress channel silent. Updated in lockstep with
@@ -368,14 +380,14 @@ app.post('/', migrationGuard, async (c) => {
368
380
  // Set only once WE created the worktree, which is also what created the
369
381
  // branch. A failure before that point must not delete a branch we never made.
370
382
  let createdBranch = false;
383
+ let createdWorktree = false;
384
+ let creationAttachments = [];
371
385
  // Hoisted out of the `build-prompt` block (declared with `const` there) so
372
386
  // both rollback paths — the `start-agent` branch and the outermost catch —
373
387
  // can read it to decide whether the setup-script caveat applies.
374
388
  let setupScriptConfigured = false;
375
389
  try {
376
- const body = await c.req
377
- .json()
378
- .catch(() => ({}));
390
+ const { body, attachments } = await readWorkspaceCreationRequest(c.req.raw);
379
391
  // workingBranch is derived from git when worktreePath is provided, so
380
392
  // it's not required in that flow. The other 3 fields stay mandatory.
381
393
  if (!body.name || !body.projectPath || !body.sourceBranch) {
@@ -671,190 +683,206 @@ app.post('/', migrationGuard, async (c) => {
671
683
  // worktree path so they stay aligned.
672
684
  currentStep = 'create-record';
673
685
  emitCreateProgress(creationId, currentStep);
674
- let prospectiveWorktreePath;
675
686
  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);
687
+ // Allocate the unique branch/path and create its checkout under one lock.
688
+ // Cleanup is deliberately outside: it acquires the same repository lock.
689
+ const creation = await withGitRepoLock(body.projectPath, async () => {
690
+ let prospectiveWorktreePath;
691
+ if (useReusedWorktree) {
692
+ // Another attachment may have completed while this request waited for the lock.
693
+ const existing = getDb().prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
694
+ if (existing) {
695
+ const failure = 'This worktree is already attached to another Kōbō workspace';
696
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
697
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
698
+ }
699
+ prospectiveWorktreePath = body.worktreePath;
695
700
  }
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', {});
701
+ else {
702
+ try {
703
+ const resolved = resolveUniqueBranchAndPath({
704
+ projectPath: body.projectPath,
705
+ baseBranch: workingBranch,
706
+ worktreesPath: globalSettings.worktreesPath,
707
+ projectSlug,
708
+ });
709
+ workingBranch = resolved.workingBranch;
710
+ prospectiveWorktreePath = resolved.worktreePath;
711
+ workingBranchAdjusted = resolved.adjusted;
712
+ }
713
+ catch (err) {
714
+ const message = err instanceof Error ? err.message : String(err);
715
+ emitCreateFailed(creationId, 'create-record', message);
716
+ return c.json({ error: message, step: 'create-record' }, 409);
717
+ }
734
718
  }
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;
719
+ let workspace = workspaceService.createWorkspace({
720
+ name: body.name,
721
+ projectPath: body.projectPath,
722
+ sourceBranch: body.sourceBranch,
723
+ workingBranch,
724
+ notionUrl: body.notionUrl,
725
+ notionPageId: body.notionPageId,
726
+ sentryUrl: body.sentryUrl,
727
+ prUrl: body.prUrl,
728
+ worktreePath: prospectiveWorktreePath,
729
+ worktreeOwned: !useReusedWorktree,
730
+ model: body.model,
731
+ brainstormModel: body.brainstormModel,
732
+ reasoningEffort: body.reasoningEffort,
733
+ agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings, body.engine ?? 'claude-code'),
734
+ engine: body.engine,
735
+ // Set by the engine-comparison flow, which posts here once per engine
736
+ // with the same id so the resulting workspaces can find each other.
737
+ comparisonId: body.comparisonId,
738
+ ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
739
+ });
740
+ createdWorkspace = workspace;
741
+ // Enable auto-loop before starting the initial brainstorming session so
742
+ // its model override is available when selecting that session's model.
743
+ if (body.autoLoop === true) {
744
+ const notionProducedTasks = body.notionUrl !== undefined &&
745
+ notionContent != null &&
746
+ notionContent.todos.length > 0 &&
747
+ notionContent.gherkinFeatures.length > 0;
748
+ const sessionMode = body.autoLoopSessionMode === 'continuous' ? 'continuous' : 'per_task';
749
+ const db = getDb();
750
+ db.prepare('UPDATE workspaces SET auto_loop = 1, auto_loop_ready = ?, auto_loop_session_mode = ? WHERE id = ?').run(notionProducedTasks ? 1 : 0, sessionMode, workspace.id);
751
+ workspace = workspaceService.getWorkspace(workspace.id) ?? workspace;
752
+ // Emit events so the frontend refreshes autoLoopStates without F5.
753
+ wsService.emitEphemeral(workspace.id, 'autoloop:enabled', {});
754
+ if (notionProducedTasks) {
755
+ wsService.emitEphemeral(workspace.id, 'autoloop:ready-flipped', {});
756
+ }
752
757
  }
753
- catch (err) {
754
- console.error('[workspaces] Failed to apply auto tags:', err);
758
+ // Auto-tag the workspace based on its creation source — `notion` when
759
+ // imported from a Notion page, `sentry` when bootstrapped from a Sentry
760
+ // issue URL. Pre-seeded in the global tag catalogue via migration v9.
761
+ // Skip any tag the user has removed from the catalogue so we respect
762
+ // their choice (they may have pruned "notion"/"sentry" on purpose).
763
+ const catalogTags = new Set(globalSettings.tags ?? []);
764
+ const autoTags = [];
765
+ if (body.notionUrl && catalogTags.has('notion'))
766
+ autoTags.push('notion');
767
+ if (body.sentryUrl && catalogTags.has('sentry'))
768
+ autoTags.push('sentry');
769
+ if (autoTags.length > 0) {
770
+ try {
771
+ const tagged = workspaceService.setWorkspaceTags(workspace.id, autoTags);
772
+ if (tagged)
773
+ workspace = tagged;
774
+ }
775
+ catch (err) {
776
+ console.error('[workspaces] Failed to apply auto tags:', err);
777
+ }
755
778
  }
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
- });
779
+ // Update workspace name with Sentry issue title if the user did not provide
780
+ // a custom name and Notion hasn't already filled it. Prefix with the Sentry
781
+ // short-id (e.g. "SEKUR-IOS-9 | TypeError: …") so the workspace stays
782
+ // identifiable in the sidebar without opening the panel.
783
+ if (sentryContent?.title && !notionContent?.title) {
784
+ const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
785
+ const renamed = resolveExtractedName(workspace.name, `${prefix}${sentryContent.title}`);
786
+ if (renamed)
787
+ workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
778
788
  }
779
- for (const feature of notionContent.gherkinFeatures) {
780
- workspaceService.createTask(workspace.id, {
781
- title: feature,
782
- isAcceptanceCriterion: true,
783
- sortOrder: sortOrder++,
784
- });
789
+ currentStep = 'create-tasks';
790
+ emitCreateProgress(creationId, currentStep);
791
+ // Create tasks from extracted Notion data
792
+ if (notionContent) {
793
+ let sortOrder = 0;
794
+ for (const todo of notionContent.todos) {
795
+ workspaceService.createTask(workspace.id, {
796
+ title: todo.title,
797
+ isAcceptanceCriterion: false,
798
+ sortOrder: sortOrder++,
799
+ });
800
+ }
801
+ for (const feature of notionContent.gherkinFeatures) {
802
+ workspaceService.createTask(workspace.id, {
803
+ title: feature,
804
+ isAcceptanceCriterion: true,
805
+ sortOrder: sortOrder++,
806
+ });
807
+ }
808
+ // Update workspace name with Notion page title only if user didn't
809
+ // provide a custom name. Prefix with the Notion unique-id (e.g.
810
+ // Use the Notion page title when the user left the generic placeholder.
811
+ if (notionContent.title) {
812
+ const renamed = resolveExtractedName(workspace.name, notionContent.title);
813
+ if (renamed)
814
+ workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
815
+ }
785
816
  }
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);
817
+ // Update workspace name with the PR/MR title if the user did not provide
818
+ // a custom name and neither Notion nor Sentry already claimed it. This
819
+ // makes the PR a THIRD context source, lowest priority of the three
820
+ // (Notion > Sentry > PR), consistent with the checks above.
821
+ if (prContent && !notionContent?.title && !sentryContent?.title) {
822
+ const renamed = resolveExtractedName(workspace.name, prContent.title);
791
823
  if (renamed)
792
824
  workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
793
825
  }
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
- });
826
+ // Create manual tasks/criteria if no Notion content was extracted
827
+ if (!notionContent && (Array.isArray(body.tasks) || Array.isArray(body.acceptanceCriteria))) {
828
+ let sortOrder = 0;
829
+ if (Array.isArray(body.tasks)) {
830
+ for (const title of body.tasks) {
831
+ if (typeof title === 'string' && title.trim()) {
832
+ workspaceService.createTask(workspace.id, {
833
+ title: title.trim(),
834
+ isAcceptanceCriterion: false,
835
+ sortOrder: sortOrder++,
836
+ });
837
+ }
815
838
  }
816
839
  }
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
- });
840
+ if (Array.isArray(body.acceptanceCriteria)) {
841
+ for (const title of body.acceptanceCriteria) {
842
+ if (typeof title === 'string' && title.trim()) {
843
+ workspaceService.createTask(workspace.id, {
844
+ title: title.trim(),
845
+ isAcceptanceCriterion: true,
846
+ sortOrder: sortOrder++,
847
+ });
848
+ }
826
849
  }
827
850
  }
828
851
  }
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;
852
+ currentStep = 'create-worktree';
853
+ emitCreateProgress(creationId, currentStep);
854
+ // Create git worktree for the working branch — unless we're reusing an
855
+ // existing one, in which case the path is taken straight from the body.
856
+ let worktreePath;
857
+ if (useReusedWorktree) {
858
+ worktreePath = body.worktreePath;
846
859
  }
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);
860
+ else {
861
+ try {
862
+ const created = await worktreeService.createWorktreeUnlocked(body.projectPath, workingBranch, baseRef, globalSettings.worktreesPath, projectSlug);
863
+ worktreePath = created.worktreePath;
864
+ // Not an assumption: `createWorktree` reports which git command it ran.
865
+ // It falls back to attaching an existing branch, and deleting that one
866
+ // on rollback would destroy commits the user made before Kobo existed.
867
+ createdBranch = created.branchCreated;
868
+ createdWorktree = true;
869
+ }
870
+ catch (err) {
871
+ if (err instanceof worktreeService.WorktreeCreationError) {
872
+ createdWorktree = true;
873
+ createdBranch = err.branchCreated;
874
+ }
875
+ throw new Error(`Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`, {
876
+ cause: err,
877
+ });
878
+ }
856
879
  }
857
- }
880
+ return { workspace, worktreePath };
881
+ });
882
+ if (creation instanceof Response)
883
+ return creation;
884
+ const { workspace } = creation;
885
+ const { worktreePath } = creation;
858
886
  currentStep = 'write-conventions';
859
887
  emitCreateProgress(creationId, currentStep);
860
888
  // Ensure Kobo-generated files are gitignored. Check both the root
@@ -871,6 +899,7 @@ app.post('/', migrationGuard, async (c) => {
871
899
  ['.ai/.git-conventions.md', '.git-conventions.md'],
872
900
  ['.ai/thoughts/', 'thoughts/'],
873
901
  ['.ai/images/', 'images/'],
902
+ ['.ai/attachments/', 'attachments/'],
874
903
  ['.ai/.setup-script.tmp', '.setup-script.tmp'],
875
904
  ['.ai/.cleanup-script.tmp', '.cleanup-script.tmp'],
876
905
  ['.ai/.archive-script.tmp', '.archive-script.tmp'],
@@ -924,6 +953,12 @@ app.post('/', migrationGuard, async (c) => {
924
953
  let agentStartError = null;
925
954
  currentStep = 'write-context-files';
926
955
  emitCreateProgress(creationId, currentStep);
956
+ creationAttachments = await saveAttachments(worktreePath, attachments);
957
+ if (creationAttachments.length > 0) {
958
+ body.description = [body.description?.trim(), ...creationAttachments.map(attachmentReference)]
959
+ .filter(Boolean)
960
+ .join('\n\n');
961
+ }
927
962
  // Save Notion content as markdown in worktree
928
963
  let notionFilePath = null;
929
964
  if (notionContent && body.notionUrl) {
@@ -1322,12 +1357,19 @@ Once the brainstorming + planning steps above are complete and you have a saved
1322
1357
  // already guards the worktree via `worktreeOwned`; the branch flag is
1323
1358
  // ours to set.
1324
1359
  const rollbackWarnings = await rollbackFailedCreation(workspace, {
1325
- deleteLocalBranch: !useReusedWorktree,
1360
+ deleteLocalBranch: createdBranch,
1361
+ removeWorktree: createdWorktree,
1362
+ attachments: creationAttachments,
1326
1363
  });
1327
1364
  // The setup script has already run by this point — see the caveat's own
1328
1365
  // comment for what that rollback cannot reach.
1329
1366
  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}`;
1367
+ const removed = createdBranch
1368
+ ? 'The workspace, its worktree and its branch were removed.'
1369
+ : createdWorktree
1370
+ ? 'The workspace and its worktree were removed.'
1371
+ : 'The half-created workspace was removed.';
1372
+ const failure = `Failed to start the agent: ${agentStartError}. ${removed}${setupCaveat}`;
1331
1373
  emitCreateFailed(creationId, 'start-agent', failure);
1332
1374
  return c.json({ error: failure, step: 'start-agent', rollback: { done: true, warnings: rollbackWarnings } }, 500);
1333
1375
  }
@@ -1372,7 +1414,11 @@ Once the brainstorming + planning steps above are complete and you have a saved
1372
1414
  // reported error.
1373
1415
  if (createdWorkspace) {
1374
1416
  emitCreateProgress(creationId, 'rollback');
1375
- const rollbackWarnings = await rollbackFailedCreation(createdWorkspace, { deleteLocalBranch: createdBranch });
1417
+ const rollbackWarnings = await rollbackFailedCreation(createdWorkspace, {
1418
+ deleteLocalBranch: createdBranch,
1419
+ removeWorktree: createdWorktree,
1420
+ attachments: creationAttachments,
1421
+ });
1376
1422
  logError('workspaces', 'Rolled back a creation that failed outside any per-step handler', {
1377
1423
  workspaceId: createdWorkspace.id,
1378
1424
  step: currentStep,
@@ -1390,7 +1436,7 @@ Once the brainstorming + planning steps above are complete and you have a saved
1390
1436
  return c.json({ error: failure, step: currentStep, rollback: { done: true, warnings: rollbackWarnings } }, 500);
1391
1437
  }
1392
1438
  emitCreateFailed(creationId, currentStep, message);
1393
- return c.json({ error: message, step: currentStep }, 500);
1439
+ return c.json({ error: message, step: currentStep }, err instanceof AttachmentRequestError ? 400 : 500);
1394
1440
  }
1395
1441
  });
1396
1442
  // POST /api/workspaces/:id/sessions — create a new idle agent session
@@ -1450,8 +1496,10 @@ app.get('/pr-states', (c) => {
1450
1496
  // stay before GET /:id.
1451
1497
  app.get('/info', (c) => {
1452
1498
  try {
1499
+ const workspaces = workspaceService.listWorkspaces(false);
1453
1500
  return c.json({
1454
- workspaces: workspaceService.listWorkspaces(false),
1501
+ workspaces,
1502
+ pendingInputs: Object.fromEntries(workspaces.map((workspace) => [workspace.id, agentManager.getPendingInputs(workspace.id)])),
1455
1503
  prSnapshots: getAllPrSnapshots(),
1456
1504
  gitStats: getAllGitStats(),
1457
1505
  // In-memory truth, not the `status` column: a workspace missing from
@@ -2119,12 +2167,8 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
2119
2167
  return c.json({ error: message }, workspaceErrorStatus(err));
2120
2168
  }
2121
2169
  });
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) => {
2170
+ // GET /api/workspaces/:id/history-search — the same logical-message index as global search.
2171
+ app.get('/:id/history-search', async (c) => {
2128
2172
  try {
2129
2173
  const id = c.req.param('id');
2130
2174
  if (!workspaceService.getWorkspace(id))
@@ -2132,71 +2176,23 @@ app.get('/:id/history-search', (c) => {
2132
2176
  const query = (c.req.query('q') ?? '').trim();
2133
2177
  if (query.length < 2)
2134
2178
  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 }];
2179
+ const limit = Math.max(1, Math.min(parseInt(c.req.query('limit') ?? '30', 10) || 30, 100));
2180
+ const results = await searchEvents(query, { workspaceId: id, includeArchived: true, limit }, c.req.raw.signal);
2181
+ return c.json({
2182
+ results: results.map((result) => ({
2183
+ eventId: result.eventId,
2184
+ sessionId: result.sessionId,
2185
+ createdAt: result.timestamp,
2186
+ kind: result.type === 'user:message' ? 'user' : 'agent',
2187
+ snippet: result.snippet,
2188
+ })),
2189
+ partial: getSearchIndexStatus().state !== 'ready',
2150
2190
  });
2151
- return c.json({ results: normalized.slice(0, limit) });
2152
2191
  }
2153
2192
  catch (err) {
2154
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2193
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 503);
2155
2194
  }
2156
2195
  });
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
2196
  /**
2201
2197
  * GET /api/workspaces/:id/sessions/:sessionId/summary — why a session ended.
2202
2198
  *
@@ -3061,7 +3057,7 @@ async function deleteWorkspaceWithSideEffectsUnlocked(workspace, opts) {
3061
3057
  if (workspace.worktreePurgedAt) {
3062
3058
  console.log(`[workspaces] skipping worktree removal on delete (already purged): ${worktreePath}`);
3063
3059
  }
3064
- else if (workspace.worktreeOwned) {
3060
+ else if (workspace.worktreeOwned && opts.removeWorktree !== false) {
3065
3061
  try {
3066
3062
  await worktreeService.removeWorktree(workspace.projectPath, worktreePath);
3067
3063
  }
@@ -3423,7 +3419,7 @@ app.post('/:id/rollback-file', async (c) => {
3423
3419
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3424
3420
  }
3425
3421
  const body = await c.req.json().catch(() => ({}));
3426
- const filePath = typeof body?.path === 'string' ? body.path.trim() : '';
3422
+ const filePath = typeof body?.path === 'string' ? body.path : '';
3427
3423
  if (!filePath) {
3428
3424
  return c.json({ error: 'Missing or invalid `path` field' }, 400);
3429
3425
  }
@@ -3436,7 +3432,13 @@ app.post('/:id/rollback-file', async (c) => {
3436
3432
  }
3437
3433
  let target;
3438
3434
  try {
3439
- target = gitOps.rollbackFile(workspace.worktreePath, workspace.workingBranch, filePath);
3435
+ target = await withGitRepoLock(workspace.worktreePath, async () => {
3436
+ if (agentManager.getAgentStatus(id) !== null)
3437
+ throw new Error('Stop the agent before rolling back a file');
3438
+ gitOps.assertCurrentBranch(workspace.worktreePath, workspace.workingBranch);
3439
+ gitOps.assertNoIndexLock(workspace.worktreePath);
3440
+ return gitOps.rollbackFile(workspace.worktreePath, workspace.workingBranch, filePath);
3441
+ });
3440
3442
  }
3441
3443
  catch (err) {
3442
3444
  const message = err instanceof Error ? err.message : String(err);