@loicngr/kobo 1.7.16 → 1.7.18

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 (86) hide show
  1. package/AGENTS.md +35 -4
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +2 -1
  4. package/dist/mcp-server/kobo-tasks-handlers.js +48 -5
  5. package/dist/mcp-server/kobo-tasks-server.js +8 -20
  6. package/dist/server/db/migrations.js +19 -0
  7. package/dist/server/db/schema.js +9 -0
  8. package/dist/server/routes/settings.js +2 -0
  9. package/dist/server/routes/workspaces.js +242 -56
  10. package/dist/server/services/agent/engines/claude-code/engine.js +51 -7
  11. package/dist/server/services/agent/engines/claude-code/options-builder.js +2 -2
  12. package/dist/server/services/agent/engines/codex/options-builder.js +2 -2
  13. package/dist/server/services/agent/orchestrator.js +1 -0
  14. package/dist/server/services/change-source-branch-service.js +150 -0
  15. package/dist/server/services/chat-history-service.js +41 -0
  16. package/dist/server/services/file-editor-service.js +59 -0
  17. package/dist/server/services/forge/github/provider.js +121 -0
  18. package/dist/server/services/forge/gitlab/provider.js +178 -0
  19. package/dist/server/services/forge/none.js +23 -0
  20. package/dist/server/services/forge/registry.js +17 -0
  21. package/dist/server/services/forge/resolve.js +34 -0
  22. package/dist/server/services/forge/types.js +9 -0
  23. package/dist/server/services/git-stats-service.js +32 -0
  24. package/dist/server/services/pr-watcher-service.js +33 -3
  25. package/dist/server/services/settings-defaults.js +77 -0
  26. package/dist/server/services/settings-service.js +34 -0
  27. package/dist/server/utils/git-ops.js +121 -134
  28. package/package.json +1 -1
  29. package/src/client/dist/spa/assets/ActivityFeed-DHf6rSgl.js +8 -0
  30. package/src/client/dist/spa/assets/{ClosePopup-CxvZA3ft.js → ClosePopup-A-tSm4aa.js} +1 -1
  31. package/src/client/dist/spa/assets/{CreatePage-CdZr7f3j.js → CreatePage-DL8LTcyD.js} +1 -1
  32. package/src/client/dist/spa/assets/DiffViewer-C4L5y8Ho.css +1 -0
  33. package/src/client/dist/spa/assets/DiffViewer-CgzuQueb.js +8 -0
  34. package/src/client/dist/spa/assets/{HealthPage-z1uIOpYk.js → HealthPage-Zsnyyv66.js} +1 -1
  35. package/src/client/dist/spa/assets/{MainLayout-BJmBXwYn.css → MainLayout-KEr19FOv.css} +1 -1
  36. package/src/client/dist/spa/assets/MainLayout-OJcuFEwx.js +37 -0
  37. package/src/client/dist/spa/assets/{QExpansionItem-BTd5m2yV.js → QExpansionItem-CgJQdznK.js} +1 -1
  38. package/src/client/dist/spa/assets/{QMenu-C2Wwwf2E.js → QMenu-NVDU7D3u.js} +1 -1
  39. package/src/client/dist/spa/assets/{QScrollArea-A1wI0IXU.js → QScrollArea-DFNGAP1T.js} +1 -1
  40. package/src/client/dist/spa/assets/{QTooltip-Bfdmzm_m.js → QTooltip-BC7PnZJ1.js} +1 -1
  41. package/src/client/dist/spa/assets/{SearchPage-ChmKHNKn.js → SearchPage-CpmeT5hL.js} +1 -1
  42. package/src/client/dist/spa/assets/{SettingsPage-BJLyYrBN.css → SettingsPage-BTGPZaqC.css} +1 -1
  43. package/src/client/dist/spa/assets/SettingsPage-CKz2kdw8.js +9 -0
  44. package/src/client/dist/spa/assets/{TouchPan-BIE5rs7U.js → TouchPan-D0fJnlOC.js} +1 -1
  45. package/src/client/dist/spa/assets/WorkspacePage-CcWa3--k.js +4 -0
  46. package/src/client/dist/spa/assets/WorkspacePage-DPj03Um2.css +1 -0
  47. package/src/client/dist/spa/assets/{build-path-tree-BGUV3nY1.js → build-path-tree-CyqReJkk.js} +1 -1
  48. package/src/client/dist/spa/assets/{cssMode-BU4X8R6a.js → cssMode-DKW40Eay.js} +1 -1
  49. package/src/client/dist/spa/assets/{editor.api-B4xBDzmJ.js → editor.api-cIZo-p3R.js} +1 -1
  50. package/src/client/dist/spa/assets/{editor.main-CSZRkloL.js → editor.main-DxYwm0in.js} +3 -3
  51. package/src/client/dist/spa/assets/{engineFeatures-CLOVr5b4.js → engineFeatures-vEC-j3xd.js} +1 -1
  52. package/src/client/dist/spa/assets/{expand-template-BxUkuL5g.js → expand-template-Bmbq9pxX.js} +1 -1
  53. package/src/client/dist/spa/assets/{freemarker2-DRz20wAV.js → freemarker2-DYe7YniO.js} +1 -1
  54. package/src/client/dist/spa/assets/{handlebars-C0dsvPnC.js → handlebars-CFHnjuEe.js} +1 -1
  55. package/src/client/dist/spa/assets/{html-Cqvj1pWs.js → html-D_DPVIcT.js} +1 -1
  56. package/src/client/dist/spa/assets/{htmlMode-BTHNvkm6.js → htmlMode-CULL5FkI.js} +1 -1
  57. package/src/client/dist/spa/assets/i18n-awaKh__J.js +1 -0
  58. package/src/client/dist/spa/assets/index-B2qdU9v-.js +52 -0
  59. package/src/client/dist/spa/assets/{javascript-C8n3U02v.js → javascript-Cj-bhbPb.js} +1 -1
  60. package/src/client/dist/spa/assets/{jsonMode-C3AFxQ6K.js → jsonMode-CJrCPpxd.js} +1 -1
  61. package/src/client/dist/spa/assets/{kobo-commands-BuxgteGZ.js → kobo-commands-B2AhWe1S.js} +1 -1
  62. package/src/client/dist/spa/assets/{liquid-C4wtUDrJ.js → liquid-B4ttnSVX.js} +1 -1
  63. package/src/client/dist/spa/assets/{mdx-CaT1p1F2.js → mdx-DT9HeWQS.js} +1 -1
  64. package/src/client/dist/spa/assets/{monaco.contribution-CJg5GKVf.js → monaco.contribution-Dc3R0xb9.js} +2 -2
  65. package/src/client/dist/spa/assets/{notifications-BC6en6Lt.js → notifications-Hq-6rEYv.js} +1 -1
  66. package/src/client/dist/spa/assets/{permissionModes-BQHBTBwa.js → permissionModes-BA0XHeew.js} +1 -1
  67. package/src/client/dist/spa/assets/{python-Cj54W2Tg.js → python-DSdYwb75.js} +1 -1
  68. package/src/client/dist/spa/assets/{razor-D3gJxoX_.js → razor-C-5bSEPf.js} +1 -1
  69. package/src/client/dist/spa/assets/{render-chat-markdown-DxEHr3lW.js → render-chat-markdown-cMOd2guW.js} +1 -1
  70. package/src/client/dist/spa/assets/{tsMode-B6S4PLWH.js → tsMode-Bck0IzqV.js} +1 -1
  71. package/src/client/dist/spa/assets/{typescript-Ca8AEX3t.js → typescript-Daj2xIGr.js} +1 -1
  72. package/src/client/dist/spa/assets/{use-onboarding-CNeLPDtv.js → use-onboarding-Xp0y257M.js} +1 -1
  73. package/src/client/dist/spa/assets/{xml-CsKo4k8C.js → xml-DLN-RVL8.js} +1 -1
  74. package/src/client/dist/spa/assets/{yaml-X5yKmi6z.js → yaml-DnDRs7J6.js} +1 -1
  75. package/src/client/dist/spa/index.html +2 -2
  76. package/src/mcp-server/kobo-tasks-handlers.ts +56 -5
  77. package/src/mcp-server/kobo-tasks-server.ts +8 -19
  78. package/src/client/dist/spa/assets/ActivityFeed-DU6lDEP0.js +0 -8
  79. package/src/client/dist/spa/assets/DiffViewer-DTdDcKZC.css +0 -1
  80. package/src/client/dist/spa/assets/DiffViewer-m801GPfI.js +0 -7
  81. package/src/client/dist/spa/assets/MainLayout-oJdQ-QKM.js +0 -37
  82. package/src/client/dist/spa/assets/SettingsPage-B59LoCos.js +0 -9
  83. package/src/client/dist/spa/assets/WorkspacePage-Bj1PJSWT.js +0 -4
  84. package/src/client/dist/spa/assets/WorkspacePage-tFBswKV9.css +0 -1
  85. package/src/client/dist/spa/assets/i18n-D1I-Us2H.js +0 -1
  86. package/src/client/dist/spa/assets/index-KABmOIkF.js +0 -2
@@ -11,12 +11,19 @@ import { listEngines } from '../services/agent/engines/registry.js';
11
11
  import * as agentManager from '../services/agent/orchestrator.js';
12
12
  import * as archiveScriptService from '../services/archive-script-service.js';
13
13
  import * as autoLoopService from '../services/auto-loop-service.js';
14
+ import { changeSourceBranch } from '../services/change-source-branch-service.js';
15
+ import { listChatHistory, pushChatHistory } from '../services/chat-history-service.js';
14
16
  import * as cronService from '../services/cron-service.js';
15
17
  import * as devServerService from '../services/dev-server-service.js';
18
+ import { saveWorkspaceFile, shaOf } from '../services/file-editor-service.js';
19
+ import { getForgeProvider } from '../services/forge/registry.js';
20
+ import { resolveForge } from '../services/forge/resolve.js';
21
+ import { ForgeUnavailableError } from '../services/forge/types.js';
22
+ import { computeGitStats } from '../services/git-stats-service.js';
16
23
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, renderNotionInitialPrompt, renderSentryInitialPrompt, } from '../services/initial-prompt-template-service.js';
17
24
  import * as notionService from '../services/notion-service.js';
18
25
  import { renderPrTemplate } from '../services/pr-template-service.js';
19
- import { getAllPrSnapshots, refreshPrSnapshot } from '../services/pr-watcher-service.js';
26
+ import { getAllGitStats, getAllPrSnapshots, refreshPrSnapshot } from '../services/pr-watcher-service.js';
20
27
  import * as quotaBackoffService from '../services/quota-backoff-service.js';
21
28
  import { getActiveReviewTemplate, renderReviewTemplate } from '../services/review-template-service.js';
22
29
  import * as sentryService from '../services/sentry-service.js';
@@ -622,8 +629,8 @@ app.post('/', migrationGuard, async (c) => {
622
629
  brainstormPrompt += `- mark_task_done(task_id) — mark a task or criterion as done\n`;
623
630
  brainstormPrompt += `\nAs you work, keep the task list up to date: call mark_task_done(task_id) as soon as you complete a task or validate a criterion — don't wait until the end. Call list_tasks() first to see the current IDs.\n`;
624
631
  }
625
- if (body.notionUrl) {
626
- brainstormPrompt += `- get_notion_ticket() — retrieve the Notion ticket info (URL, ticket ID, extracted content)\n`;
632
+ if (body.notionUrl || body.sentryUrl) {
633
+ brainstormPrompt += `- get_ticket() — retrieve the mission's source-of-truth (source URL + extracted ticket/issue content), whether it comes from Notion or Sentry\n`;
627
634
  }
628
635
  brainstormPrompt += `- kobo__set_workspace_agent_description(description) — keep the workspace's agent_description up to date as a short one-line summary of what you're currently doing or have just accomplished. The user sees this in the sidebar without opening the workspace. Update it whenever your focus shifts (e.g. "Investigating SERVICE-1600 → enriching local Notion file", then "Writing failing test for FacturX validator"). Plain text, max 200 chars. The current value is in kobo__get_workspace_info.\nThere is also a separate user-controlled \`description\` field on the workspace — DO NOT touch it. Only set_workspace_agent_description is yours to write; the user owns the other one.\n`;
629
636
  brainstormPrompt += `- kobo__cron_create(expression, prompt, label?, mode?, oneShot?) — schedule a (recurring or one-shot) trigger on THIS workspace. At each fire Kōbō waits for the workspace to be idle and then injects \`prompt\` as the next user message. \`expression\` is a standard 5-field cron (\`min hour dom month dow\`) or a helper (\`@hourly\`, \`@daily\`, \`@weekly\`, \`@monthly\`, \`@yearly\`). Examples: \`*/30 * * * *\` = every 30 min; \`0 9 * * 1\` = every Monday at 9am; \`0 14 7 6 *\` = 7 June at 14:00. \`mode\` is \`'resume'\` (default — every fire continues the SAME conversation that scheduled the cron, so you can chain follow-ups) or \`'fresh'\` (every fire starts a brand-new session with a clean context, ideal for periodic checks like CI watch). \`oneShot\` (default false): when true, the cron cancels itself after the first real fire — use this to trigger once at a specific time without recurring. Skip-if-active: occurrences fired while a session is running are skipped, the next is computed, and the cron continues. Persists across restarts. Returns a cron \`id\`.\n`;
@@ -770,6 +777,23 @@ app.get('/pr-states', (c) => {
770
777
  return c.json({ error: message }, 500);
771
778
  }
772
779
  });
780
+ // GET /api/workspaces/info — bulk snapshot for the 30s client refresh:
781
+ // non-archived workspace rows + cached PR snapshots + cached git stats.
782
+ // Pure read — no git/forge calls on the request path. Static route: must
783
+ // stay before GET /:id.
784
+ app.get('/info', (c) => {
785
+ try {
786
+ return c.json({
787
+ workspaces: workspaceService.listWorkspaces(false),
788
+ prSnapshots: getAllPrSnapshots(),
789
+ gitStats: getAllGitStats(),
790
+ });
791
+ }
792
+ catch (err) {
793
+ const message = err instanceof Error ? err.message : String(err);
794
+ return c.json({ error: message }, 500);
795
+ }
796
+ });
773
797
  // POST /api/workspaces/pr-snapshot/refresh/:id — on-demand refresh of a single
774
798
  // workspace's PR snapshot, driven by the Git tab refresh button. Static prefix
775
799
  // keeps it ahead of `/:id` in the Hono matcher.
@@ -1443,6 +1467,81 @@ ${AUTO_LOOP_HARD_RULES}`;
1443
1467
  return c.json({ error: message }, 500);
1444
1468
  }
1445
1469
  });
1470
+ // GET /:id/chat-history — list up to 200 most recent chat-input messages
1471
+ // for this workspace, ordered newest-first. Placed BEFORE `app.get('/:id', …)`
1472
+ // so the more-specific path wins.
1473
+ app.get('/:id/chat-history', (c) => {
1474
+ try {
1475
+ const id = c.req.param('id');
1476
+ const workspace = workspaceService.getWorkspace(id);
1477
+ if (!workspace)
1478
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1479
+ return c.json({ history: listChatHistory(id) });
1480
+ }
1481
+ catch (err) {
1482
+ const message = err instanceof Error ? err.message : String(err);
1483
+ return c.json({ error: message }, 500);
1484
+ }
1485
+ });
1486
+ // POST /:id/chat-history — append a message to this workspace's history.
1487
+ // Body: { message: string }. 204 on success.
1488
+ app.post('/:id/chat-history', async (c) => {
1489
+ try {
1490
+ const id = c.req.param('id');
1491
+ const workspace = workspaceService.getWorkspace(id);
1492
+ if (!workspace)
1493
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1494
+ if (workspace.archivedAt) {
1495
+ return c.json({ error: `Workspace '${id}' is archived` }, 400);
1496
+ }
1497
+ const body = (await c.req.json().catch(() => ({})));
1498
+ if (typeof body.message !== 'string' || body.message.trim().length === 0) {
1499
+ return c.json({ error: 'message is required' }, 400);
1500
+ }
1501
+ pushChatHistory(id, body.message);
1502
+ return c.body(null, 204);
1503
+ }
1504
+ catch (err) {
1505
+ const message = err instanceof Error ? err.message : String(err);
1506
+ return c.json({ error: message }, 500);
1507
+ }
1508
+ });
1509
+ // POST /:id/save-file — persist edited file content from the diff viewer.
1510
+ // Body: { path, content, baseSha }. Refuses while the agent runs (409). When
1511
+ // the file's current sha differs from baseSha, returns 412 with `currentSha`.
1512
+ app.post('/:id/save-file', async (c) => {
1513
+ try {
1514
+ const id = c.req.param('id');
1515
+ const workspace = workspaceService.getWorkspace(id);
1516
+ if (!workspace)
1517
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1518
+ if (workspace.archivedAt) {
1519
+ return c.json({ error: `Workspace '${id}' is archived` }, 400);
1520
+ }
1521
+ if (agentManager.getAgentStatus(id) !== null) {
1522
+ return c.json({ error: 'Cannot save while the agent is running — stop it first' }, 409);
1523
+ }
1524
+ const body = (await c.req.json().catch(() => ({})));
1525
+ if (typeof body.path !== 'string' || body.path.trim().length === 0) {
1526
+ return c.json({ error: 'path is required' }, 400);
1527
+ }
1528
+ if (typeof body.content !== 'string') {
1529
+ return c.json({ error: 'content is required (string)' }, 400);
1530
+ }
1531
+ if (typeof body.baseSha !== 'string' || body.baseSha.length === 0) {
1532
+ return c.json({ error: 'baseSha is required' }, 400);
1533
+ }
1534
+ const result = saveWorkspaceFile(workspace.worktreePath, body.path, body.content, body.baseSha);
1535
+ if (result.status === 'conflict') {
1536
+ return c.json({ error: 'File changed on disk', currentSha: result.currentSha }, 412);
1537
+ }
1538
+ return c.body(null, 204);
1539
+ }
1540
+ catch (err) {
1541
+ const message = err instanceof Error ? err.message : String(err);
1542
+ return c.json({ error: message }, 500);
1543
+ }
1544
+ });
1446
1545
  // GET /api/workspaces/:id — get workspace details with tasks
1447
1546
  app.get('/:id', (c) => {
1448
1547
  try {
@@ -1914,33 +2013,18 @@ app.get('/:id/git-stats', async (c) => {
1914
2013
  if (!workspace) {
1915
2014
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1916
2015
  }
1917
- const worktreePath = workspace.worktreePath;
1918
2016
  const freshFetch = c.req.query('freshFetch') === '1';
1919
2017
  if (freshFetch) {
1920
- await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
2018
+ await gitOps.fetchSourceBranchAsync(workspace.worktreePath, workspace.sourceBranch);
1921
2019
  }
1922
2020
  else {
1923
- // Fire-and-forget: explicitly catch to avoid unhandled rejection if the
1924
- // helper ever changes contract and starts rejecting.
1925
- void gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch).catch(() => { });
1926
- }
1927
- const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1928
- const behindCount = gitOps.getCommitsBehind(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1929
- const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1930
- const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
1931
- const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath, workspace.workingBranch);
1932
- const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
1933
- return c.json({
1934
- commitCount,
1935
- behindCount,
1936
- filesChanged: diffStats.filesChanged,
1937
- insertions: diffStats.insertions,
1938
- deletions: diffStats.deletions,
1939
- prUrl: pr?.url ?? null,
1940
- prState: pr?.state ?? null,
1941
- unpushedCount,
1942
- workingTree,
1943
- });
2021
+ // Fire-and-forget: explicitly catch to avoid unhandled rejection.
2022
+ void gitOps.fetchSourceBranchAsync(workspace.worktreePath, workspace.sourceBranch).catch(() => { });
2023
+ }
2024
+ const forgeProvider = getForgeProvider(resolveForge(workspace.projectPath));
2025
+ const pr = await forgeProvider.getPrStatus(workspace.worktreePath, workspace.workingBranch);
2026
+ const stats = await computeGitStats(workspace, pr);
2027
+ return c.json(stats);
1944
2028
  }
1945
2029
  catch (err) {
1946
2030
  const message = err instanceof Error ? err.message : String(err);
@@ -2002,7 +2086,13 @@ app.get('/:id/diff-file', (c) => {
2002
2086
  const original = gitOps.getFileAtRef(worktreePath, baseRef, filePath);
2003
2087
  const modified = gitOps.getFileContent(worktreePath, filePath);
2004
2088
  c.header('Cache-Control', 'no-store');
2005
- return c.json({ original: original ?? '', modified: modified ?? '', filePath, mode });
2089
+ return c.json({
2090
+ original: original ?? '',
2091
+ modified: modified ?? '',
2092
+ filePath,
2093
+ mode,
2094
+ modifiedSha: shaOf(modified ?? ''),
2095
+ });
2006
2096
  }
2007
2097
  catch (err) {
2008
2098
  const message = err instanceof Error ? err.message : String(err);
@@ -2341,7 +2431,9 @@ app.post('/:id/git/resolve-with-agent', migrationGuard, async (c) => {
2341
2431
  const workspace = workspaceService.getWorkspace(id);
2342
2432
  if (!workspace)
2343
2433
  return c.json({ error: `Workspace '${id}' not found` }, 404);
2344
- const body = (await c.req.json().catch(() => ({})));
2434
+ const body = (await c.req
2435
+ .json()
2436
+ .catch(() => ({})));
2345
2437
  const worktreePath = workspace.worktreePath;
2346
2438
  const operation = body.operation ?? gitOps.getOngoingGitOperation(worktreePath) ?? 'merge';
2347
2439
  const files = body.files && body.files.length > 0 ? body.files : gitOps.getConflictedFiles(worktreePath);
@@ -2349,7 +2441,16 @@ app.post('/:id/git/resolve-with-agent', migrationGuard, async (c) => {
2349
2441
  return c.json({ error: 'No conflicted files detected — nothing for the agent to resolve' }, 400);
2350
2442
  }
2351
2443
  const fileList = files.map((f) => `- ${f}`).join('\n');
2352
- const continueCmd = operation === 'merge' ? 'git merge --continue' : 'git rebase --continue';
2444
+ const continueCmd = operation === 'merge'
2445
+ ? 'git merge --continue'
2446
+ : operation === 'cherry-pick'
2447
+ ? 'git cherry-pick --continue'
2448
+ : 'git rebase --continue';
2449
+ // During a cherry-pick the conflict sides are inverted vs a rebase:
2450
+ // `ours` = the new base, `theirs` = the feature commit being applied.
2451
+ const cherryPickNote = operation === 'cherry-pick'
2452
+ ? '\n\n**Cherry-pick note:** during a cherry-pick, `ours` is the new base branch and `theirs` is our feature commit being replayed — the inverse of a rebase. `git checkout --theirs <file>` takes OUR feature version.'
2453
+ : '';
2353
2454
  const prompt = `I started a \`git ${operation}\` of \`origin/${workspace.sourceBranch}\` into our working branch \`${workspace.workingBranch}\` and it produced conflicts that I need your help to resolve INTELLIGENTLY.
2354
2455
 
2355
2456
  Conflicted files (${files.length}):
@@ -2371,7 +2472,7 @@ ${fileList}
2371
2472
  3. Edit the file to the correct merged state and remove the conflict markers.
2372
2473
  4. Run the test suite to verify no regression (\`npm test\` or the project's equivalent).
2373
2474
  5. \`git add <resolved-files>\` then \`${continueCmd}\`.
2374
- 6. Report the summary: which files you touched, the key decisions you made, and the final test result.
2475
+ 6. Report the summary: which files you touched, the key decisions you made, and the final test result.${cherryPickNote}
2375
2476
 
2376
2477
  Start now.`;
2377
2478
  // Persist the prompt in the chat feed so the user sees what was dispatched.
@@ -2403,7 +2504,7 @@ Start now.`;
2403
2504
  return c.json({ error: message }, 500);
2404
2505
  }
2405
2506
  });
2406
- /** Change the base branch of an existing PR via gh CLI. */
2507
+ /** Change the base branch of an existing PR via the resolved forge provider. */
2407
2508
  app.post('/:id/change-pr-base', async (c) => {
2408
2509
  try {
2409
2510
  const id = c.req.param('id');
@@ -2413,8 +2514,90 @@ app.post('/:id/change-pr-base', async (c) => {
2413
2514
  const workspace = workspaceService.getWorkspace(id);
2414
2515
  if (!workspace)
2415
2516
  return c.json({ error: `Workspace '${id}' not found` }, 404);
2416
- const worktreePath = workspace.worktreePath;
2417
- await execFileAsync('gh', ['pr', 'edit', '--base', body.base], { cwd: worktreePath });
2517
+ const provider = getForgeProvider(resolveForge(workspace.projectPath));
2518
+ if (!provider.capabilities.canChangePrBase) {
2519
+ return c.json({ error: 'This project has no forge that supports changing the PR base', code: 'forge_unsupported' }, 409);
2520
+ }
2521
+ const availability = await provider.isAvailable(workspace.worktreePath);
2522
+ if (!availability.available) {
2523
+ return c.json({
2524
+ error: `Forge CLI unavailable (${availability.reason ?? 'unknown'})`,
2525
+ code: `forge_${availability.reason ?? 'unavailable'}`,
2526
+ }, 409);
2527
+ }
2528
+ await provider.changePrBase(workspace.worktreePath, body.base);
2529
+ return c.json({ success: true });
2530
+ }
2531
+ catch (err) {
2532
+ if (err instanceof ForgeUnavailableError) {
2533
+ return c.json({ error: err.message, code: 'forge_unavailable' }, 409);
2534
+ }
2535
+ const message = err instanceof Error ? err.message : String(err);
2536
+ return c.json({ error: message }, 500);
2537
+ }
2538
+ });
2539
+ /** Re-target a workspace onto a new source branch (metadata + worktree + PR base). */
2540
+ app.post('/:id/change-source-branch', async (c) => {
2541
+ try {
2542
+ const id = c.req.param('id');
2543
+ const body = await c.req.json();
2544
+ if (!body.newBase)
2545
+ return c.json({ error: 'Missing newBase parameter' }, 400);
2546
+ const workspace = workspaceService.getWorkspace(id);
2547
+ if (!workspace)
2548
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
2549
+ const result = await changeSourceBranch(id, body.newBase);
2550
+ if (result.status === 'too-many') {
2551
+ return c.json({ error: 'The branch has too many own commits — rebase it manually', code: 'too_many_commits', ...result }, 409);
2552
+ }
2553
+ if (result.status === 'dirty') {
2554
+ return c.json({ error: 'Commit or stash your changes before changing the source branch', code: 'dirty_worktree', ...result }, 409);
2555
+ }
2556
+ return c.json(result);
2557
+ }
2558
+ catch (err) {
2559
+ const message = err instanceof Error ? err.message : String(err);
2560
+ if (/agent is running/i.test(message)) {
2561
+ return c.json({ error: message, code: 'agent_running' }, 409);
2562
+ }
2563
+ if (/does not exist|already '|is required/i.test(message)) {
2564
+ return c.json({ error: message }, 400);
2565
+ }
2566
+ return c.json({ error: message }, 500);
2567
+ }
2568
+ });
2569
+ /** Cancel an in-flight source-branch change: abort the cherry-pick, restore the
2570
+ * working branch from its latest backup branch, revert the source metadata. */
2571
+ app.post('/:id/cancel-source-change', async (c) => {
2572
+ try {
2573
+ const id = c.req.param('id');
2574
+ const body = await c.req.json();
2575
+ if (!body.previousBase)
2576
+ return c.json({ error: 'Missing previousBase parameter' }, 400);
2577
+ const workspace = workspaceService.getWorkspace(id);
2578
+ if (!workspace)
2579
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
2580
+ const backups = gitOps.listBackupBranches(workspace.worktreePath, workspace.workingBranch);
2581
+ if (backups.length === 0) {
2582
+ return c.json({ error: 'No backup branch found — cannot auto-restore', code: 'no_backup' }, 409);
2583
+ }
2584
+ gitOps.restoreBranchFromBackup(workspace.worktreePath, workspace.workingBranch, backups[0]);
2585
+ workspaceService.updateWorkspaceSourceBranch(id, body.previousBase);
2586
+ return c.json({ success: true, restoredFrom: backups[0] });
2587
+ }
2588
+ catch (err) {
2589
+ const message = err instanceof Error ? err.message : String(err);
2590
+ return c.json({ error: message }, 500);
2591
+ }
2592
+ });
2593
+ /** Force-push the working branch with --force-with-lease (after a history rewrite). */
2594
+ app.post('/:id/force-push', (c) => {
2595
+ try {
2596
+ const id = c.req.param('id');
2597
+ const workspace = workspaceService.getWorkspace(id);
2598
+ if (!workspace)
2599
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
2600
+ gitOps.pushBranch(workspace.worktreePath, workspace.workingBranch, { force: true });
2418
2601
  return c.json({ success: true });
2419
2602
  }
2420
2603
  catch (err) {
@@ -2463,36 +2646,39 @@ app.post('/:id/open-pr', async (c) => {
2463
2646
  }
2464
2647
  return c.json({ error: `Failed to check branch state: ${message}` }, 500);
2465
2648
  }
2466
- // Create PR via GitHub CLI
2467
- let ghOutput;
2649
+ // Create the PR/MR via the resolved forge provider.
2650
+ const provider = getForgeProvider(resolveForge(workspace.projectPath));
2651
+ if (!provider.capabilities.canCreatePr) {
2652
+ return c.json({ error: 'This project has no forge that can open a PR', code: 'forge_unsupported' }, 409);
2653
+ }
2654
+ const availability = await provider.isAvailable(worktreePath);
2655
+ if (!availability.available) {
2656
+ return c.json({
2657
+ error: `Forge CLI unavailable (${availability.reason ?? 'unknown'})`,
2658
+ code: `forge_${availability.reason ?? 'unavailable'}`,
2659
+ }, 409);
2660
+ }
2661
+ let prUrl;
2662
+ let prNumber;
2468
2663
  try {
2469
2664
  const placeholderBody = 'Automated PR — description will be updated by the agent.';
2470
- const { stdout } = await execFileAsync('gh', [
2471
- 'pr',
2472
- 'create',
2473
- '--base',
2474
- workspace.sourceBranch,
2475
- '--head',
2476
- workspace.workingBranch,
2477
- '--title',
2478
- workspace.name,
2479
- '--body',
2480
- placeholderBody,
2481
- ], { cwd: worktreePath });
2482
- ghOutput = stdout;
2665
+ const created = await provider.createPr(worktreePath, {
2666
+ base: workspace.sourceBranch,
2667
+ head: workspace.workingBranch,
2668
+ title: workspace.name,
2669
+ body: placeholderBody,
2670
+ });
2671
+ prUrl = created.url;
2672
+ prNumber = created.number;
2483
2673
  }
2484
2674
  catch (err) {
2675
+ if (err instanceof ForgeUnavailableError) {
2676
+ return c.json({ error: err.message, code: 'forge_unavailable' }, 409);
2677
+ }
2485
2678
  const message = err instanceof Error ? err.message : String(err);
2486
2679
  const stderr = err.stderr?.toString() ?? '';
2487
- return c.json({ error: `gh pr create failed: ${message} ${stderr}`.trim() }, 500);
2488
- }
2489
- // Parse PR URL and number from gh output
2490
- const urlMatch = ghOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
2491
- if (!urlMatch) {
2492
- return c.json({ error: 'Could not parse PR URL from gh output' }, 500);
2680
+ return c.json({ error: `Failed to open PR: ${message} ${stderr}`.trim() }, 500);
2493
2681
  }
2494
- const prUrl = urlMatch[0];
2495
- const prNumber = parseInt(urlMatch[1], 10);
2496
2682
  // ── From here on, PR exists. No more 5xx responses. ──
2497
2683
  // Resolve the PR prompt template; skip message steps if empty
2498
2684
  const effective = settingsService.getEffectiveSettings(workspace.projectPath);
@@ -5,6 +5,14 @@ import { createMapperState, mapSdkMessage, QUOTA_PATTERN, tryEmitQuota } from '.
5
5
  import { buildClaudeOptions } from './options-builder.js';
6
6
  import { buildPreCompactCustomInstructions } from './precompact-hook.js';
7
7
  import { resolveClaudeBinaryPath } from './resolve-binary.js';
8
+ /**
9
+ * Grace window between the SDK's terminal `result` message and the generator
10
+ * reaching `done`. A healthy run closes within milliseconds; if the generator
11
+ * stays parked past this (a hung subagent task or stuck MCP/teardown), the
12
+ * post-result drain watchdog force-emits `session:ended` so the orchestrator
13
+ * and auto-loop are not frozen forever.
14
+ */
15
+ const RESULT_DRAIN_TIMEOUT_MS = 15_000;
8
16
  function toMcpServersMap(specs) {
9
17
  if (!specs || specs.length === 0)
10
18
  return undefined;
@@ -139,6 +147,37 @@ export function createClaudeCodeEngine() {
139
147
  };
140
148
  let iteratorRunning = false;
141
149
  let userInterrupted = false;
150
+ // Guard so the post-result drain watchdog and the natural loop exit (or
151
+ // catch block) never both emit `session:ended` for the same run.
152
+ let sessionEndedEmitted = false;
153
+ const emitSessionEnded = (reason, exitCode) => {
154
+ if (sessionEndedEmitted)
155
+ return;
156
+ sessionEndedEmitted = true;
157
+ safeEmit({ kind: 'session:ended', reason, exitCode });
158
+ };
159
+ // Post-result drain watchdog. The SDK emits a terminal `result` message
160
+ // when the turn completes; the generator should then reach `done`
161
+ // near-instantly. If it stays parked (a hung subagent task or stuck
162
+ // teardown), the `for await` below would wait forever — `session:ended`
163
+ // would never fire, freezing the orchestrator and the auto-loop. Once a
164
+ // `result` is observed we arm a timer that force-emits `session:ended`
165
+ // with the result's own outcome, then aborts the generator best-effort.
166
+ let resultDrainTimer;
167
+ const armResultDrainWatchdog = () => {
168
+ if (resultDrainTimer)
169
+ return;
170
+ resultDrainTimer = setTimeout(() => {
171
+ console.warn(`[claude-engine] SDK generator still open ${RESULT_DRAIN_TIMEOUT_MS}ms after 'result' — forcing session:ended`);
172
+ const reason = userInterrupted ? 'killed' : mapperState.sawErrorResult ? 'error' : 'completed';
173
+ emitSessionEnded(reason, reason === 'completed' ? 0 : null);
174
+ // Best-effort: unstick the SDK so its subprocesses / MCP children
175
+ // tear down. The session is reported ended regardless of whether
176
+ // the abort actually propagates through the parked generator.
177
+ abortController.abort();
178
+ }, RESULT_DRAIN_TIMEOUT_MS);
179
+ resultDrainTimer.unref?.();
180
+ };
142
181
  const iteratorPromise = (async () => {
143
182
  iteratorRunning = true;
144
183
  try {
@@ -149,6 +188,8 @@ export function createClaudeCodeEngine() {
149
188
  discoveredSessionId = ev.engineSessionId;
150
189
  safeEmit(ev);
151
190
  }
191
+ if (msg.type === 'result')
192
+ armResultDrainWatchdog();
152
193
  }
153
194
  // If the SDK ended with a `result.subtype === 'error_*'`, the
154
195
  // event-mapper already surfaced an `error` event but the iterator
@@ -158,11 +199,7 @@ export function createClaudeCodeEngine() {
158
199
  // `error_during_execution`, which the mapper suppresses) — report
159
200
  // it as `killed`, consistent with the catch-block abort path.
160
201
  const endReason = userInterrupted ? 'killed' : mapperState.sawErrorResult ? 'error' : 'completed';
161
- safeEmit({
162
- kind: 'session:ended',
163
- reason: endReason,
164
- exitCode: endReason === 'completed' ? 0 : null,
165
- });
202
+ emitSessionEnded(endReason, endReason === 'completed' ? 0 : null);
166
203
  }
167
204
  catch (err) {
168
205
  // Treat any abort we triggered (stop() → abortController.abort()) as
@@ -174,7 +211,7 @@ export function createClaudeCodeEngine() {
174
211
  abortController.signal.aborted ||
175
212
  /aborted by user|process aborted|abortError|ede_diagnostic/i.test(error.message ?? '');
176
213
  if (isAbort) {
177
- safeEmit({ kind: 'session:ended', reason: 'killed', exitCode: null });
214
+ emitSessionEnded('killed', null);
178
215
  }
179
216
  else {
180
217
  safeEmit({
@@ -182,10 +219,17 @@ export function createClaudeCodeEngine() {
182
219
  category: 'spawn_failed',
183
220
  message: error.message,
184
221
  });
185
- safeEmit({ kind: 'session:ended', reason: 'error', exitCode: null });
222
+ emitSessionEnded('error', null);
186
223
  }
187
224
  }
188
225
  finally {
226
+ // The post-result drain watchdog (if armed) is moot once the
227
+ // iterator has exited — clear it so a healthy run never triggers a
228
+ // stray abort after it already ended.
229
+ if (resultDrainTimer) {
230
+ clearTimeout(resultDrainTimer);
231
+ resultDrainTimer = undefined;
232
+ }
189
233
  // Drain any callback still pending (SDK terminated while awaiting an
190
234
  // answer). canUseTool's abort path covers signalled stops; this
191
235
  // covers natural iterator completion.
@@ -6,9 +6,9 @@ const KOBO_MCP_BRIEF = [
6
6
  'Conventions — read these BEFORE starting work, not as a fallback:',
7
7
  '• `kobo__list_tasks` first on any non-trivial turn, then `kobo__mark_task_done` as each item completes.',
8
8
  '• `kobo__list_documents` / `kobo__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
9
- '• `kobo__log_thought` to persist notable decisions to `.ai/thoughts/<date>-<slug>.md`.',
9
+ '• `kobo__log_thought` to persist notable decisions to `.ai/thoughts/logs/<date>-<slug>.md`.',
10
10
  '• `kobo__search_codebase` to recall prior chat history (conversations, not source — use Grep for source).',
11
- '• `kobo__get_workspace_info` / `kobo__get_git_info` / `kobo__get_notion_ticket` for context.',
11
+ '• `kobo__get_workspace_info` / `kobo__get_git_info` / `kobo__get_ticket` for context.',
12
12
  '• `kobo__set_workspace_status` when the mission is done / blocked / idle.',
13
13
  '• `kobo__schedule_wakeup` / `kobo__cancel_wakeup` to schedule (or cancel) a follow-up session — prefer these over the built-in `ScheduleWakeup` tool.',
14
14
  'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
@@ -4,9 +4,9 @@ const CODEX_KOBO_MCP_BRIEF = [
4
4
  'Conventions — read these BEFORE starting work, not as a fallback:',
5
5
  '• `mcp__kobo-tasks__list_tasks` first on any non-trivial turn, then `mcp__kobo-tasks__mark_task_done` as each item completes.',
6
6
  '• `mcp__kobo-tasks__list_documents` / `mcp__kobo-tasks__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
7
- '• `mcp__kobo-tasks__log_thought` to persist notable decisions to `.ai/thoughts/<date>-<slug>.md`.',
7
+ '• `mcp__kobo-tasks__log_thought` to persist notable decisions to `.ai/thoughts/logs/<date>-<slug>.md`.',
8
8
  '• `mcp__kobo-tasks__search_codebase` to recall prior chat history (conversations, not source — use shell tools for source).',
9
- '• `mcp__kobo-tasks__get_workspace_info` / `mcp__kobo-tasks__get_git_info` / `mcp__kobo-tasks__get_notion_ticket` for context.',
9
+ '• `mcp__kobo-tasks__get_workspace_info` / `mcp__kobo-tasks__get_git_info` / `mcp__kobo-tasks__get_ticket` for context.',
10
10
  '• `mcp__kobo-tasks__set_workspace_status` when the mission is done / blocked / idle.',
11
11
  '• `mcp__kobo-tasks__schedule_wakeup` / `mcp__kobo-tasks__cancel_wakeup` to schedule (or cancel) a follow-up session.',
12
12
  'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
@@ -313,6 +313,7 @@ function readEffectiveSettingsSafe(projectPath) {
313
313
  cleanupScriptMode: 'no-tasks',
314
314
  cleanupScriptOnlyOnChanges: false,
315
315
  archiveScript: '',
316
+ changeSourceBranchScript: '',
316
317
  notionStatusProperty: '',
317
318
  notionInProgressStatus: '',
318
319
  };