@loicngr/kobo 2.1.15 → 2.1.16

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 (113) hide show
  1. package/AGENTS.md +9 -0
  2. package/CHANGELOG.md +4 -0
  3. package/CONFIGURATION.md +13 -1
  4. package/README.md +3 -1
  5. package/dist/mcp-server/kobo-tasks-server.js +19 -104
  6. package/dist/mcp-server/workspace-dialogue-client.js +15 -3
  7. package/dist/server/db/migrations.js +2 -0
  8. package/dist/server/db/schema.js +2 -0
  9. package/dist/server/db/workspace-message-batch-schema.js +9 -0
  10. package/dist/server/index.js +6 -0
  11. package/dist/server/routes/mcp.js +69 -3
  12. package/dist/server/routes/workspace-messages.js +22 -0
  13. package/dist/server/routes/workspaces.js +58 -3
  14. package/dist/server/services/agent/orchestrator.js +1 -2
  15. package/dist/server/services/agent/session-controller.js +4 -1
  16. package/dist/server/services/pr-checkout-rollback-service.js +51 -0
  17. package/dist/server/services/pr-checkout-service.js +21 -1
  18. package/dist/server/services/workspace-creation-mcp-service.js +198 -0
  19. package/dist/server/services/workspace-group-message-mcp-service.js +58 -0
  20. package/dist/server/services/workspace-group-message-service.js +182 -0
  21. package/dist/server/services/workspace-lifecycle-mcp-service.js +72 -0
  22. package/dist/server/services/workspace-message-service.js +5 -0
  23. package/dist/shared/pr-checkout-steps.js +86 -0
  24. package/dist/shared/workspace-creation-tools.js +182 -0
  25. package/dist/shared/workspace-group-message-tools.js +134 -0
  26. package/dist/shared/workspace-group-messages.js +27 -0
  27. package/dist/shared/workspace-lifecycle-tools.js +112 -0
  28. package/package.json +1 -1
  29. package/src/client/dist/pwa/assets/ActivityFeed-tJmJhmtl.js +2 -0
  30. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-lKjfaC6V.js → AutoLoopFinalReviewSettings-Bbrq02Xt.js} +1 -1
  31. package/src/client/dist/pwa/assets/{ChangelogPage-BplM7qEB.js → ChangelogPage-BJfkAVYp.js} +1 -1
  32. package/src/client/dist/pwa/assets/{ComparisonPanel-DxK5v9w_.js → ComparisonPanel-BfP8APKY.js} +1 -1
  33. package/src/client/dist/pwa/assets/CreatePage-BbtNfo0x.js +2 -0
  34. package/src/client/dist/pwa/assets/{DashboardPage-BsP-MATS.js → DashboardPage-DZYsAh5y.js} +1 -1
  35. package/src/client/dist/pwa/assets/DiffViewer-4Q-GJgyA.js +8 -0
  36. package/src/client/dist/pwa/assets/{DocumentsPanel-CGrf8Q3H.js → DocumentsPanel-BfPlfiwU.js} +1 -1
  37. package/src/client/dist/pwa/assets/{DrawerToggleButton-BANeLSsR.js → DrawerToggleButton-DG1Qjqk1.js} +1 -1
  38. package/src/client/dist/pwa/assets/GitPanel-ECnbb_rx.js +3 -0
  39. package/src/client/dist/pwa/assets/{HealthPage-BVTNx-92.js → HealthPage-_VXG1Zeo.js} +1 -1
  40. package/src/client/dist/pwa/assets/MainLayout-BH6grTuW.js +2 -0
  41. package/src/client/dist/pwa/assets/{MainLayout-C6nvIQjU.css → MainLayout-DnmUxNk7.css} +1 -1
  42. package/src/client/dist/pwa/assets/{MemoryPanel-C71rTpv9.js → MemoryPanel-CVlvGZXZ.js} +1 -1
  43. package/src/client/dist/pwa/assets/{MemoryProposalList-CoqhUGrv.js → MemoryProposalList-SJsnzUAM.js} +1 -1
  44. package/src/client/dist/pwa/assets/{QBtnDropdown-U9Ey5W8h.js → QBtnDropdown-DBgGsN-D.js} +1 -1
  45. package/src/client/dist/pwa/assets/{QExpansionItem-Be_A1MT1.js → QExpansionItem-DKgaMJ_l.js} +1 -1
  46. package/src/client/dist/pwa/assets/{QScrollArea-D4lYzAPQ.js → QScrollArea-D3OCsq3g.js} +1 -1
  47. package/src/client/dist/pwa/assets/QSelect-Cb_s_nox.js +1 -0
  48. package/src/client/dist/pwa/assets/{QTooltip-kStOgY0X.js → QTooltip-X5ps8Opw.js} +1 -1
  49. package/src/client/dist/pwa/assets/{SchedulePanel-C7nTGdWE.js → SchedulePanel-Ww_FVlW5.js} +1 -1
  50. package/src/client/dist/pwa/assets/{SearchPage-C6J72pkQ.js → SearchPage-B87WEHf3.js} +1 -1
  51. package/src/client/dist/pwa/assets/SettingsPage-BAZLGUyg.js +9 -0
  52. package/src/client/dist/pwa/assets/{SplitWorkspacePage-Bqn4gdVw.js → SplitWorkspacePage-B99AJUbG.js} +1 -1
  53. package/src/client/dist/pwa/assets/{TerminalPanel-zLeDCReI.js → TerminalPanel-DnA1MXwu.js} +10 -10
  54. package/src/client/dist/pwa/assets/{ToolCallItem-B8b_Sagj.js → ToolCallItem-2vFggaur.js} +3 -3
  55. package/src/client/dist/pwa/assets/{TouchPan-Bl8VzIDK.js → TouchPan-BSU3z0aO.js} +1 -1
  56. package/src/client/dist/pwa/assets/{TourReplayButton-pL0HCboU.js → TourReplayButton-DQwS2xdD.js} +1 -1
  57. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-9Eomx0rQ.js → WorkspaceAttentionLabels-CM1tGDje.js} +1 -1
  58. package/src/client/dist/pwa/assets/WorkspacePage-CQN6EKIn.js +4 -0
  59. package/src/client/dist/pwa/assets/{browser-C-PJ4alw.js → browser-DPsupI9Q.js} +1 -1
  60. package/src/client/dist/pwa/assets/{build-path-tree-SW9rj7B_.js → build-path-tree-CbIdt-p9.js} +1 -1
  61. package/src/client/dist/pwa/assets/{custom-sounds-toz-4drX.js → custom-sounds-DR68eyF8.js} +1 -1
  62. package/src/client/dist/pwa/assets/{de-Ddxe8cc6.js → de-D3Ycvf_F.js} +2 -2
  63. package/src/client/dist/pwa/assets/{editor.api-BJTPhnh8.js → editor.api-DvgAtnOj.js} +1 -1
  64. package/src/client/dist/pwa/assets/{efforts-BO9xGn9q.js → efforts-nmxfTVQD.js} +1 -1
  65. package/src/client/dist/pwa/assets/es-TgeS5898.js +17 -0
  66. package/src/client/dist/pwa/assets/{expand-template-BOZMtWau.js → expand-template-Brn5O4a1.js} +1 -1
  67. package/src/client/dist/pwa/assets/{format-hEnpRyit.js → format-DfJ3Fv1i.js} +1 -1
  68. package/src/client/dist/pwa/assets/{formatters-DD_eFc4z.js → formatters-B30bNFhq.js} +1 -1
  69. package/src/client/dist/pwa/assets/fr-CJafryNb.js +17 -0
  70. package/src/client/dist/pwa/assets/{freemarker2-B6nUofvT.js → freemarker2-DnZe74tc.js} +1 -1
  71. package/src/client/dist/pwa/assets/{handlebars-4P3gtM8p.js → handlebars-CmuMfxzO.js} +1 -1
  72. package/src/client/dist/pwa/assets/{html-C9ZzWo-y.js → html-BD7OJFGX.js} +1 -1
  73. package/src/client/dist/pwa/assets/i18n-BY4JeBjk.js +1 -0
  74. package/src/client/dist/pwa/assets/index-C5fAw9ll.js +20 -0
  75. package/src/client/dist/pwa/assets/it-C5mN8Y61.js +17 -0
  76. package/src/client/dist/pwa/assets/{javascript-BGEC5ggd.js → javascript-Y-OrgcmW.js} +1 -1
  77. package/src/client/dist/pwa/assets/{liquid-DNtF630O.js → liquid-BqOkKePH.js} +1 -1
  78. package/src/client/dist/pwa/assets/{mdx-CICwyOd0.js → mdx-D-58x2h0.js} +1 -1
  79. package/src/client/dist/pwa/assets/{monaco-contributions-BkuwvgrT.js → monaco-contributions-BW4ZMKO6.js} +1 -1
  80. package/src/client/dist/pwa/assets/{monaco.contribution-GX8XA-PE.js → monaco.contribution-DJWqzNG-.js} +2 -2
  81. package/src/client/dist/pwa/assets/network-auth-BGPRhUa6.js +1 -0
  82. package/src/client/dist/pwa/assets/notify-theme-DZDzfxph.js +1 -0
  83. package/src/client/dist/pwa/assets/{project-color-B1SMFjSe.js → project-color-gB5yeZPN.js} +1 -1
  84. package/src/client/dist/pwa/assets/{python-n5uPdTVS.js → python-BkBj1wDC.js} +1 -1
  85. package/src/client/dist/pwa/assets/{razor-AOj0nsUt.js → razor-DOOJ_0b8.js} +1 -1
  86. package/src/client/dist/pwa/assets/{typescript-Uhkrqihh.js → typescript-Bheo43G5.js} +1 -1
  87. package/src/client/dist/pwa/assets/{use-search-index-status-CW0BdpBZ.js → use-search-index-status-CBpnyV-R.js} +1 -1
  88. package/src/client/dist/pwa/assets/use-tours-BHNasCvg.js +2 -0
  89. package/src/client/dist/pwa/assets/{use-worktree-restore-BZxibZS5.js → use-worktree-restore-CgABsWno.js} +1 -1
  90. package/src/client/dist/pwa/assets/{workspace-templates-W557e1-m.js → workspace-templates-BnFkigYg.js} +1 -1
  91. package/src/client/dist/pwa/assets/{xml-B-hnMgEs.js → xml-vHt2BD26.js} +1 -1
  92. package/src/client/dist/pwa/assets/{yaml-DqXkTvgm.js → yaml-BvhLPzP9.js} +1 -1
  93. package/src/client/dist/pwa/index.html +1 -1
  94. package/src/client/dist/pwa/sw.js +1 -1
  95. package/src/mcp-server/README.md +130 -12
  96. package/src/mcp-server/kobo-tasks-server.ts +27 -105
  97. package/src/mcp-server/workspace-dialogue-client.ts +18 -3
  98. package/src/client/dist/pwa/assets/ActivityFeed-D7jouiuP.js +0 -2
  99. package/src/client/dist/pwa/assets/CreatePage-D5ilP5kt.js +0 -2
  100. package/src/client/dist/pwa/assets/DiffViewer-Cd15vFWj.js +0 -8
  101. package/src/client/dist/pwa/assets/GitPanel-CEPV5Qw0.js +0 -3
  102. package/src/client/dist/pwa/assets/MainLayout-DBKtwilW.js +0 -2
  103. package/src/client/dist/pwa/assets/QSelect-fS0DLOd1.js +0 -1
  104. package/src/client/dist/pwa/assets/SettingsPage-SuH-aIzv.js +0 -9
  105. package/src/client/dist/pwa/assets/WorkspacePage-B6clgURl.js +0 -4
  106. package/src/client/dist/pwa/assets/es-DBJIQeWW.js +0 -17
  107. package/src/client/dist/pwa/assets/fr-yBzIIPaQ.js +0 -17
  108. package/src/client/dist/pwa/assets/i18n-2-ULabk2.js +0 -1
  109. package/src/client/dist/pwa/assets/index-Vk99jtxD.js +0 -20
  110. package/src/client/dist/pwa/assets/it-D44U-PT1.js +0 -17
  111. package/src/client/dist/pwa/assets/network-auth-KnWzjYIc.js +0 -1
  112. package/src/client/dist/pwa/assets/notify-theme-CWuZTz4Z.js +0 -1
  113. package/src/client/dist/pwa/assets/use-tours-CndFlyL-.js +0 -2
@@ -105,8 +105,16 @@ app.use('/:id/*', async (c, next) => {
105
105
  }
106
106
  await next();
107
107
  });
108
- // These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
109
- for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
108
+ // Branch/path mutations must also exclude teardown across awaits.
109
+ // Purge/delete/restore own their guard in the service.
110
+ for (const route of [
111
+ '/:id/archive',
112
+ '/:id/run-setup-script',
113
+ '/:id/cancel-source-change',
114
+ '/:id/rollback-file',
115
+ '/:id/rename-branch',
116
+ '/:id/resync-branch',
117
+ ]) {
110
118
  app.use(route, async (c, next) => {
111
119
  try {
112
120
  await withWorkspaceLifecycleGuard(c.req.param('id'), next);
@@ -570,6 +578,7 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
570
578
  // the working branch from git itself — the body.workingBranch is ignored.
571
579
  let useReusedWorktree = false;
572
580
  let reusedDerivedBranch = null;
581
+ let reusedIdentity = null;
573
582
  if (body.worktreePath) {
574
583
  currentStep = 'inspect-worktree';
575
584
  emitCreateProgress(creationId, currentStep);
@@ -598,6 +607,14 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
598
607
  return c.json({ error: failure, step: 'inspect-worktree' }, 422);
599
608
  }
600
609
  reusedDerivedBranch = branch;
610
+ const directory = fs.statSync(body.worktreePath);
611
+ const gitEntry = fs.statSync(path.join(body.worktreePath, '.git'));
612
+ reusedIdentity = {
613
+ device: directory.dev,
614
+ inode: directory.ino,
615
+ gitDevice: gitEntry.dev,
616
+ gitInode: gitEntry.ino,
617
+ };
601
618
  }
602
619
  catch (err) {
603
620
  const message = err instanceof Error ? err.message : String(err);
@@ -781,6 +798,36 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
781
798
  const creation = await withGitRepoLock(body.projectPath, async () => {
782
799
  let prospectiveWorktreePath;
783
800
  if (useReusedWorktree) {
801
+ // Imports above may yield long enough for a failed PR creation to remove
802
+ // its checkout. Recheck the exact directory and branch under the same
803
+ // repository lock as compensation before any record/context files exist.
804
+ try {
805
+ const worktreePath = body.worktreePath;
806
+ const directory = fs.statSync(worktreePath);
807
+ const gitEntry = fs.statSync(path.join(worktreePath, '.git'));
808
+ const commonDir = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
809
+ encoding: 'utf-8',
810
+ timeout: 30_000,
811
+ }).trim();
812
+ const branch = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
813
+ encoding: 'utf-8',
814
+ timeout: 30_000,
815
+ }).trim();
816
+ if (!reusedIdentity ||
817
+ !directory.isDirectory() ||
818
+ directory.dev !== reusedIdentity.device ||
819
+ directory.ino !== reusedIdentity.inode ||
820
+ gitEntry.dev !== reusedIdentity.gitDevice ||
821
+ gitEntry.ino !== reusedIdentity.gitInode ||
822
+ realPathOrResolved(commonDir) !== realPathOrResolved(path.join(body.projectPath, '.git')) ||
823
+ branch !== reusedDerivedBranch)
824
+ throw new Error('The worktree directory, repository or branch changed during creation; retry from a fresh diagnosis');
825
+ }
826
+ catch (err) {
827
+ const failure = `Failed to revalidate worktree: ${err instanceof Error ? err.message : String(err)}`;
828
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
829
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
830
+ }
784
831
  // Another attachment may have completed while this request waited for the lock.
785
832
  const existing = getDb().prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
786
833
  if (existing) {
@@ -3180,6 +3227,12 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
3180
3227
  return c.json({ error: message }, workspaceErrorStatus(err));
3181
3228
  }
3182
3229
  });
3230
+ class WorkspaceBranchConfirmationError extends Error {
3231
+ code = 'branch-confirmation-mismatch';
3232
+ constructor() {
3233
+ super('The workspace branch changed or does not match confirmationBranch. Confirm its current branch before deleting.');
3234
+ }
3235
+ }
3183
3236
  // Shared teardown for a single workspace: stops the agent, destroys the
3184
3237
  // terminal, removes the owned worktree, optionally deletes local/remote
3185
3238
  // branches, then deletes the DB row (cascades to tasks/sessions/events).
@@ -3191,6 +3244,8 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
3191
3244
  const current = workspaceService.getWorkspace(workspace.id);
3192
3245
  if (!current)
3193
3246
  throw new Error(`Workspace '${workspace.id}' not found`);
3247
+ if (opts.confirmationBranch !== undefined && opts.confirmationBranch !== current.workingBranch)
3248
+ throw new WorkspaceBranchConfirmationError();
3194
3249
  // A bulk deletion may have captured the list before restoration completed.
3195
3250
  if (workspace.archivedAt && !current.archivedAt) {
3196
3251
  throw new Error('Workspace was unarchived while deletion was pending. Retry from its current state.');
@@ -3335,7 +3390,7 @@ app.delete('/:id', migrationGuard, async (c) => {
3335
3390
  return c.json({ ok: true, warnings }, 200);
3336
3391
  }
3337
3392
  catch (err) {
3338
- if (err instanceof WorkspaceLifecycleBusyError)
3393
+ if (err instanceof WorkspaceLifecycleBusyError || err instanceof WorkspaceBranchConfirmationError)
3339
3394
  return c.json({ code: err.code, error: err.message }, 409);
3340
3395
  const message = err instanceof Error ? err.message : String(err);
3341
3396
  return c.json({ error: message }, workspaceErrorStatus(err));
@@ -1947,8 +1947,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
1947
1947
  throw new Error(`Session '${expectedSessionId}' is not active for workspace '${workspaceId}'`);
1948
1948
  }
1949
1949
  wakeupService.cancel(workspaceId, 'user-message');
1950
- beforeDispatch?.();
1951
- await ctrl.sendMessage(content);
1950
+ await ctrl.sendMessage(content, beforeDispatch);
1952
1951
  }
1953
1952
  export async function sendMessageForFallback(workspaceId, content) {
1954
1953
  assertWorkspaceLifecycleAvailable(workspaceId);
@@ -106,7 +106,7 @@ export class SessionController {
106
106
  }
107
107
  this._status = 'running';
108
108
  }
109
- async sendMessage(content) {
109
+ async sendMessage(content, beforeDispatch) {
110
110
  if (!this._startPromise)
111
111
  throw new Error('SessionController not started');
112
112
  await this._startPromise;
@@ -114,6 +114,9 @@ export class SessionController {
114
114
  throw new Error('SessionController is stopping');
115
115
  if (!this._engineProcess)
116
116
  throw new Error('SessionController not started');
117
+ // Startup may have yielded while the workspace's delivery mode changed.
118
+ // Validate immediately before handing the message to the engine.
119
+ beforeDispatch?.();
117
120
  await this._engineProcess.sendMessage(content);
118
121
  }
119
122
  interrupt() {
@@ -0,0 +1,51 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getDb } from '../db/index.js';
5
+ import { buildNonInteractiveGitEnv } from '../utils/git-ops.js';
6
+ import { withGitRepoLock } from '../utils/git-repo-lock.js';
7
+ /** Undo only our unchanged checkout, never the PR branch or earlier checkout decisions. */
8
+ export async function rollbackCreatedPrCheckout(projectPath, checkout) {
9
+ const retained = (reason) => ({ removed: false, worktreePath: checkout.worktreePath, reason });
10
+ if (!checkout.createdWorktree)
11
+ return retained('The checkout existed before this request');
12
+ try {
13
+ return await withGitRepoLock(projectPath, () => {
14
+ // Workspace adoption uses this same lock. Keep checks and removal synchronous
15
+ // so another request cannot attach this checkout between the ownership read and removal.
16
+ const owners = getDb().prepare('SELECT worktree_path FROM workspaces').all();
17
+ const resolved = fs.realpathSync(checkout.worktreePath);
18
+ if (owners.some(({ worktree_path }) => {
19
+ try {
20
+ return fs.realpathSync(worktree_path) === resolved;
21
+ }
22
+ catch {
23
+ return path.resolve(worktree_path) === resolved;
24
+ }
25
+ }))
26
+ return retained('The checkout is attached to a workspace');
27
+ const stat = fs.lstatSync(checkout.worktreePath);
28
+ const identity = checkout.createdWorktree;
29
+ if (!stat.isDirectory() || stat.dev !== identity.device || stat.ino !== identity.inode)
30
+ return retained('The checkout directory was replaced');
31
+ const git = (cwd, args) => execFileSync('git', args, {
32
+ cwd,
33
+ encoding: 'utf8',
34
+ timeout: 30_000,
35
+ maxBuffer: 16 * 1024 * 1024,
36
+ env: buildNonInteractiveGitEnv(),
37
+ }).trim();
38
+ if (git(resolved, ['rev-parse', 'HEAD']) !== identity.head ||
39
+ git(resolved, ['symbolic-ref', '--short', 'HEAD']) !== checkout.workingBranch)
40
+ return retained('The checkout branch or HEAD changed');
41
+ if (git(resolved, ['status', '--porcelain', '--untracked-files=all', '--ignored']))
42
+ return retained('The checkout contains local or ignored files');
43
+ // No --force: Git performs its own final dirty/locked-worktree checks.
44
+ git(projectPath, ['worktree', 'remove', '--', resolved]);
45
+ return { removed: true, worktreePath: checkout.worktreePath };
46
+ });
47
+ }
48
+ catch (error) {
49
+ return retained(error instanceof Error ? error.message : String(error));
50
+ }
51
+ }
@@ -227,6 +227,8 @@ export async function applyBranchStrategy(repoPath, branch, strategy) {
227
227
  if (strategy === 'keep')
228
228
  return { strategy, backupBranch: null };
229
229
  if (strategy === 'fast-forward') {
230
+ // A caller cannot turn fast-forward into a silent reset of local commits.
231
+ await readGitAsync(repoPath, ['merge-base', '--is-ancestor', branch, remoteRef]);
230
232
  await runGitAsync(repoPath, ['branch', '--force', branch, remoteRef]);
231
233
  return { strategy, backupBranch: null };
232
234
  }
@@ -301,6 +303,9 @@ function resolvePrCheckoutLocked(input, owner) {
301
303
  const fresh = await diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
302
304
  if ((await computeFingerprint(fresh)) !== input.fingerprint)
303
305
  throw new StaleDiagnosisError(fresh);
306
+ if (input.decisions.divergence === 'fast-forward' &&
307
+ (fresh.branch.state === 'ahead' || fresh.branch.state === 'diverged'))
308
+ throw new Error('Fast-forward cannot discard local commits. Keep, rebase or explicitly reset the branch.');
304
309
  if ((fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
305
310
  input.decisions.orphanWorktree === 'create-elsewhere') {
306
311
  throw new Error('Branch is already checked out. Attach its existing worktree instead.');
@@ -392,7 +397,16 @@ function resolvePrCheckoutLocked(input, owner) {
392
397
  // realignment from step 4 (if any) is NOT undone here — a `reset-hard`
393
398
  // strategy already left its own `kobo-backup/<branch>-<ts>` safety net,
394
399
  // and fast-forward/rebase are non-destructive by construction.
400
+ let createdWorktree;
395
401
  try {
402
+ if (created) {
403
+ const stat = fs.lstatSync(worktreePath);
404
+ createdWorktree = {
405
+ device: stat.dev,
406
+ inode: stat.ino,
407
+ head: await runGitAsync(worktreePath, ['rev-parse', 'HEAD']),
408
+ };
409
+ }
396
410
  input.afterWorktreeHook?.();
397
411
  }
398
412
  catch (err) {
@@ -409,7 +423,13 @@ function resolvePrCheckoutLocked(input, owner) {
409
423
  }
410
424
  throw err;
411
425
  }
412
- return { worktreePath, workingBranch: input.headBranch, sourceBranch: input.baseBranch, applied };
426
+ return {
427
+ worktreePath,
428
+ workingBranch: input.headBranch,
429
+ sourceBranch: input.baseBranch,
430
+ applied,
431
+ ...(createdWorktree ? { createdWorktree } : {}),
432
+ };
413
433
  });
414
434
  }
415
435
  /** Realign a branch from inside its own worktree, where ref surgery is refused. */
@@ -0,0 +1,198 @@
1
+ import { checkoutDecisionError, deriveSteps } from '../../shared/pr-checkout-steps.js';
2
+ import { validateWorkspaceCreationArguments } from '../../shared/workspace-creation-tools.js';
3
+ import { CLAUDE_CODE_CAPABILITIES } from './agent/engines/claude-code/capabilities.js';
4
+ import { CODEX_CAPABILITIES } from './agent/engines/codex/capabilities.js';
5
+ import { rollbackCreatedPrCheckout } from './pr-checkout-rollback-service.js';
6
+ export class WorkspaceCreationMcpError extends Error {
7
+ status;
8
+ stage;
9
+ details;
10
+ constructor(message, status, stage, details = null) {
11
+ super(message);
12
+ this.status = status;
13
+ this.stage = stage;
14
+ this.details = details;
15
+ this.name = 'WorkspaceCreationMcpError';
16
+ }
17
+ }
18
+ async function request(dispatch, path, stage, body, init) {
19
+ const response = await dispatch(path, init ?? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) });
20
+ const payload = await response.json().catch(() => null);
21
+ if (!response.ok)
22
+ throw new WorkspaceCreationMcpError(payload?.error ?? `Workspace request failed (${response.status})`, response.status, stage, payload);
23
+ return payload;
24
+ }
25
+ function prIdentity(raw) {
26
+ const url = new URL(raw);
27
+ const pathname = url.pathname.replace(/\/+$/, '');
28
+ const last = pathname.split('/').at(-1);
29
+ const number = Number(last);
30
+ if (!/^[1-9]\d*$/.test(last) || !Number.isSafeInteger(number))
31
+ throw new WorkspaceCreationMcpError('pr_url must end with a PR/MR number', 400, 'validate');
32
+ return { number, identity: `${url.origin}${pathname}` };
33
+ }
34
+ function mapInput(input) {
35
+ const mapping = {
36
+ name: 'name',
37
+ project_path: 'projectPath',
38
+ source_branch: 'sourceBranch',
39
+ working_branch: 'workingBranch',
40
+ model: 'model',
41
+ reasoning_effort: 'reasoningEffort',
42
+ brainstorm_model: 'brainstormModel',
43
+ brainstorm_reasoning_effort: 'brainstormReasoningEffort',
44
+ engine: 'engine',
45
+ description: 'description',
46
+ tags: 'tags',
47
+ tasks: 'tasks',
48
+ acceptance_criteria: 'acceptanceCriteria',
49
+ agent_permission_mode: 'agentPermissionMode',
50
+ auto_loop: 'autoLoop',
51
+ auto_loop_session_mode: 'autoLoopSessionMode',
52
+ skip_setup_script: 'skipSetupScript',
53
+ workflow_policy: 'workflowPolicy',
54
+ notion_url: 'notionUrl',
55
+ notion_page_id: 'notionPageId',
56
+ sentry_url: 'sentryUrl',
57
+ pr_url: 'prUrl',
58
+ worktree_path: 'worktreePath',
59
+ comparison_id: 'comparisonId',
60
+ creation_id: 'creationId',
61
+ };
62
+ const body = {};
63
+ for (const [key, field] of Object.entries(mapping))
64
+ if (Object.hasOwn(input, key))
65
+ body[field] = input[key];
66
+ if (input.auto_loop_final_review !== undefined) {
67
+ const config = input.auto_loop_final_review;
68
+ body.autoLoopFinalReview =
69
+ config === null
70
+ ? null
71
+ : {
72
+ engine: config.engine,
73
+ model: config.model,
74
+ reasoningEffort: config.reasoning_effort,
75
+ additionalInstructions: config.additional_instructions ?? '',
76
+ };
77
+ }
78
+ return body;
79
+ }
80
+ /** Reuses the application's route validation, lifecycle guards, Git locks and rollback. */
81
+ export async function executeWorkspaceCreationTool(name, raw, dispatch) {
82
+ let input;
83
+ try {
84
+ input = validateWorkspaceCreationArguments(name, raw);
85
+ }
86
+ catch (error) {
87
+ throw new WorkspaceCreationMcpError(error instanceof Error ? error.message : 'Invalid arguments', 400, 'validate');
88
+ }
89
+ const config = input.auto_loop_final_review;
90
+ if (config) {
91
+ const capabilities = config.engine === 'codex' ? CODEX_CAPABILITIES : CLAUDE_CODE_CAPABILITIES;
92
+ if (!capabilities.effortLevels?.some((effort) => effort.id === config.reasoning_effort))
93
+ throw new WorkspaceCreationMcpError('Unsupported review reasoning effort', 400, 'validate');
94
+ }
95
+ const body = mapInput(input);
96
+ let resolvedCheckout;
97
+ if (input.pr_url) {
98
+ const supplied = prIdentity(input.pr_url);
99
+ const diagnosis = await request(dispatch, '/api/pull-requests/diagnose', 'diagnose', {
100
+ projectPath: input.project_path,
101
+ prNumber: supplied.number,
102
+ });
103
+ const requiresAction = (reason) => ({
104
+ created: false,
105
+ requiresAction: true,
106
+ ...diagnosis,
107
+ ...(reason ? { reason } : {}),
108
+ });
109
+ if (diagnosis.pr && prIdentity(diagnosis.pr.url).identity !== supplied.identity)
110
+ throw new WorkspaceCreationMcpError('pr_url does not belong to the selected project', 400, 'diagnose', diagnosis);
111
+ if (name === 'diagnose_workspace_pr')
112
+ return diagnosis;
113
+ if (!diagnosis.pr)
114
+ return requiresAction('Pull request unavailable');
115
+ const { report, pr, fingerprint } = diagnosis;
116
+ const plan = input.pr_checkout;
117
+ if (plan && plan.fingerprint !== fingerprint)
118
+ throw new WorkspaceCreationMcpError('The repository changed since the diagnosis was taken', 409, 'diagnose', diagnosis);
119
+ const decisions = plan?.decisions ?? {};
120
+ if (report.workspace.state === 'active' && decisions.existingWorkspace === 'open')
121
+ return { created: false, workspaceId: report.workspace.id, workspace: report.workspace };
122
+ if (report.workspace.state === 'archived' && decisions.archivedWorkspace === 'unarchive') {
123
+ const workspace = await request(dispatch, `/api/workspaces/${encodeURIComponent(report.workspace.id)}/unarchive`, 'unarchive');
124
+ return { created: false, workspaceId: report.workspace.id, workspace };
125
+ }
126
+ // A purge or an existing association must never fall through to a second creation.
127
+ if (report.workspace.state !== 'none')
128
+ return requiresAction('Use the existing workspace; automatic duplicate creation is not supported');
129
+ if (decisions.ongoingOperation === 'cancel')
130
+ return { created: false, cancelled: true, ...diagnosis };
131
+ const error = checkoutDecisionError(report, decisions);
132
+ if (error)
133
+ throw new WorkspaceCreationMcpError(error, 400, 'validate-checkout', diagnosis);
134
+ const steps = deriveSteps(report);
135
+ if (steps.some((step) => step.blocking))
136
+ return requiresAction('Checkout is blocked');
137
+ if (!plan && (steps.length > 0 || report.worktree.state !== 'none'))
138
+ return requiresAction();
139
+ if (plan) {
140
+ // Every state requiring a choice needs an explicit decision. In particular,
141
+ // do not inherit the UI's defaults for reset/stash/abort or path selection.
142
+ const missing = steps.some((step) => (step.id === 'path' && !decisions.pathCollision) ||
143
+ (step.id === 'worktree' && !decisions.orphanWorktree) ||
144
+ (step.id === 'operation' && !decisions.ongoingOperation) ||
145
+ (step.id === 'changes' && !decisions.localChanges) ||
146
+ (step.id === 'divergence' && !decisions.divergence));
147
+ if (missing)
148
+ return requiresAction('Explicit checkout decisions are required');
149
+ }
150
+ body.sourceBranch = pr.baseBranch;
151
+ body.workingBranch = pr.headBranch;
152
+ body.prUrl = pr.url;
153
+ body.skipSetupScript = input.skip_setup_script ?? true;
154
+ if (input.worktree_path) {
155
+ const diagnosedPath = 'path' in report.worktree ? report.worktree.path : null;
156
+ if (diagnosedPath !== input.worktree_path)
157
+ throw new WorkspaceCreationMcpError('worktree_path does not match the diagnosed PR checkout', 400, 'validate-checkout', diagnosis);
158
+ }
159
+ const resolved = await request(dispatch, '/api/pull-requests/resolve', 'resolve', {
160
+ projectPath: input.project_path,
161
+ prNumber: pr.number,
162
+ headBranch: pr.headBranch,
163
+ baseBranch: pr.baseBranch,
164
+ decisions,
165
+ fingerprint,
166
+ });
167
+ body.worktreePath = resolved.worktreePath;
168
+ resolvedCheckout = resolved;
169
+ }
170
+ try {
171
+ const attachments = input.attachments;
172
+ if (attachments?.length) {
173
+ const form = new FormData();
174
+ form.append('workspace', JSON.stringify(body));
175
+ for (const file of attachments)
176
+ form.append('attachments', new Blob([Buffer.from(file.data_base64, 'base64')], { type: file.mime_type }), file.name);
177
+ return await request(dispatch, '/api/workspaces', 'create', undefined, { method: 'POST', body: form });
178
+ }
179
+ return await request(dispatch, '/api/workspaces', 'create', body);
180
+ }
181
+ catch (error) {
182
+ if (!resolvedCheckout)
183
+ throw error;
184
+ if (!(error instanceof WorkspaceCreationMcpError))
185
+ throw new WorkspaceCreationMcpError(error instanceof Error ? error.message : String(error), 500, 'create', {
186
+ checkoutRecovery: {
187
+ removed: false,
188
+ worktreePath: resolvedCheckout.worktreePath,
189
+ reason: 'Creation outcome is unknown; inspect the workspace and checkout before retrying',
190
+ },
191
+ });
192
+ const checkoutRecovery = await rollbackCreatedPrCheckout(input.project_path, resolvedCheckout);
193
+ throw new WorkspaceCreationMcpError(error.message, error.status, error.stage, {
194
+ ...(error.details && typeof error.details === 'object' ? error.details : {}),
195
+ checkoutRecovery,
196
+ });
197
+ }
198
+ }
@@ -0,0 +1,58 @@
1
+ import { validateWorkspaceGroupMessageArguments } from '../../shared/workspace-group-message-tools.js';
2
+ import { MAX_GROUP_MESSAGE_RECIPIENTS, matchesGroupMessageFilters } from '../../shared/workspace-group-messages.js';
3
+ import { GroupMessageError, getGroupMessageBatch, startGroupMessageBatch } from './workspace-group-message-service.js';
4
+ import { listWorkspaces } from './workspace-service.js';
5
+ export class WorkspaceGroupMessageMcpError extends Error {
6
+ status;
7
+ constructor(message, status) {
8
+ super(message);
9
+ this.status = status;
10
+ }
11
+ }
12
+ export function executeWorkspaceGroupMessageTool(name, raw, source) {
13
+ let input;
14
+ try {
15
+ input = validateWorkspaceGroupMessageArguments(name, raw);
16
+ }
17
+ catch (error) {
18
+ throw new WorkspaceGroupMessageMcpError(error instanceof Error ? error.message : 'Invalid arguments', 400);
19
+ }
20
+ if (name === 'preview_workspace_group_message') {
21
+ const filters = {
22
+ tags: input.tags,
23
+ statuses: input.statuses,
24
+ devServerRunning: input.dev_server_running,
25
+ };
26
+ const candidates = listWorkspaces()
27
+ .filter((workspace) => !workspace.archivedAt && !workspace.worktreePurgedAt && matchesGroupMessageFilters(workspace, filters))
28
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
29
+ const offset = input.offset ?? 0;
30
+ const limit = input.limit ?? MAX_GROUP_MESSAGE_RECIPIENTS;
31
+ return {
32
+ total: candidates.length,
33
+ offset,
34
+ limit,
35
+ recipients: candidates.slice(offset, offset + limit).map((workspace) => ({
36
+ workspaceId: workspace.id,
37
+ name: workspace.name,
38
+ tags: workspace.tags,
39
+ status: workspace.status,
40
+ delivery: workspace.autoLoop ? 'next_iteration' : 'immediate',
41
+ })),
42
+ };
43
+ }
44
+ if (name === 'get_workspace_group_message') {
45
+ const batch = getGroupMessageBatch(input.request_id);
46
+ if (!batch)
47
+ throw new WorkspaceGroupMessageMcpError('Group message request not found', 404);
48
+ return batch;
49
+ }
50
+ try {
51
+ return startGroupMessageBatch({ requestId: input.request_id, workspaceIds: input.workspace_ids, content: input.content }, source);
52
+ }
53
+ catch (error) {
54
+ if (error instanceof GroupMessageError)
55
+ throw new WorkspaceGroupMessageMcpError(error.message, error.status);
56
+ throw error;
57
+ }
58
+ }