@loicngr/kobo 1.7.16 → 1.7.17
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.
- package/AGENTS.md +35 -4
- package/CHANGELOG.md +5 -0
- package/README.md +2 -1
- package/dist/mcp-server/kobo-tasks-handlers.js +48 -5
- package/dist/mcp-server/kobo-tasks-server.js +8 -20
- package/dist/server/db/migrations.js +19 -0
- package/dist/server/db/schema.js +9 -0
- package/dist/server/routes/settings.js +2 -0
- package/dist/server/routes/workspaces.js +242 -56
- package/dist/server/services/agent/engines/claude-code/engine.js +51 -7
- package/dist/server/services/agent/engines/claude-code/options-builder.js +2 -2
- package/dist/server/services/agent/engines/codex/options-builder.js +2 -2
- package/dist/server/services/agent/orchestrator.js +1 -0
- package/dist/server/services/change-source-branch-service.js +150 -0
- package/dist/server/services/chat-history-service.js +41 -0
- package/dist/server/services/file-editor-service.js +59 -0
- package/dist/server/services/forge/github/provider.js +121 -0
- package/dist/server/services/forge/gitlab/provider.js +178 -0
- package/dist/server/services/forge/none.js +23 -0
- package/dist/server/services/forge/registry.js +17 -0
- package/dist/server/services/forge/resolve.js +34 -0
- package/dist/server/services/forge/types.js +9 -0
- package/dist/server/services/git-stats-service.js +32 -0
- package/dist/server/services/pr-watcher-service.js +33 -3
- package/dist/server/services/settings-defaults.js +77 -0
- package/dist/server/services/settings-service.js +34 -0
- package/dist/server/utils/git-ops.js +121 -134
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-CDhLuhI3.js +8 -0
- package/src/client/dist/spa/assets/{ClosePopup-CxvZA3ft.js → ClosePopup-A-tSm4aa.js} +1 -1
- package/src/client/dist/spa/assets/{CreatePage-CdZr7f3j.js → CreatePage-xIjxPliD.js} +1 -1
- package/src/client/dist/spa/assets/DiffViewer-C4L5y8Ho.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-CcgF65Mo.js +8 -0
- package/src/client/dist/spa/assets/{HealthPage-z1uIOpYk.js → HealthPage-Bw-9__wY.js} +1 -1
- package/src/client/dist/spa/assets/MainLayout-C2ULeep-.js +37 -0
- package/src/client/dist/spa/assets/{MainLayout-BJmBXwYn.css → MainLayout-KEr19FOv.css} +1 -1
- package/src/client/dist/spa/assets/{QExpansionItem-BTd5m2yV.js → QExpansionItem-CgJQdznK.js} +1 -1
- package/src/client/dist/spa/assets/{QMenu-C2Wwwf2E.js → QMenu-NVDU7D3u.js} +1 -1
- package/src/client/dist/spa/assets/{QScrollArea-A1wI0IXU.js → QScrollArea-_Ji1cgqL.js} +1 -1
- package/src/client/dist/spa/assets/{QTooltip-Bfdmzm_m.js → QTooltip-BC7PnZJ1.js} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-ChmKHNKn.js → SearchPage-D2x2X7K7.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-BLb9B9iY.js +9 -0
- package/src/client/dist/spa/assets/{SettingsPage-BJLyYrBN.css → SettingsPage-BTGPZaqC.css} +1 -1
- package/src/client/dist/spa/assets/{TouchPan-BIE5rs7U.js → TouchPan-D0fJnlOC.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-BlAVs03z.js +4 -0
- package/src/client/dist/spa/assets/{WorkspacePage-tFBswKV9.css → WorkspacePage-DTV0oWHS.css} +1 -1
- package/src/client/dist/spa/assets/{build-path-tree-BGUV3nY1.js → build-path-tree-CyqReJkk.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-BU4X8R6a.js → cssMode-BsT_HBz-.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-B4xBDzmJ.js → editor.api-CIxiApSC.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-CSZRkloL.js → editor.main-D-1-e3_n.js} +3 -3
- package/src/client/dist/spa/assets/{engineFeatures-CLOVr5b4.js → engineFeatures-baMvMT98.js} +1 -1
- package/src/client/dist/spa/assets/{expand-template-BxUkuL5g.js → expand-template-CF0lBr4L.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-DRz20wAV.js → freemarker2-q2PyKiM2.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-C0dsvPnC.js → handlebars-DoaZIK6r.js} +1 -1
- package/src/client/dist/spa/assets/{html-Cqvj1pWs.js → html-DHcse-fd.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-BTHNvkm6.js → htmlMode-DPZCU7DB.js} +1 -1
- package/src/client/dist/spa/assets/i18n-DwzfgKc3.js +1 -0
- package/src/client/dist/spa/assets/index-DMUFfCIq.js +52 -0
- package/src/client/dist/spa/assets/{javascript-C8n3U02v.js → javascript-Ddw7c3eO.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-C3AFxQ6K.js → jsonMode-vmAmvg_N.js} +1 -1
- package/src/client/dist/spa/assets/{kobo-commands-BuxgteGZ.js → kobo-commands-B2AhWe1S.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-C4wtUDrJ.js → liquid-Bwz3vr4k.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-CaT1p1F2.js → mdx-B2uBtVef.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-CJg5GKVf.js → monaco.contribution-B3cRHiXp.js} +2 -2
- package/src/client/dist/spa/assets/{notifications-BC6en6Lt.js → notifications-Hq-6rEYv.js} +1 -1
- package/src/client/dist/spa/assets/{permissionModes-BQHBTBwa.js → permissionModes-DuwIe4ty.js} +1 -1
- package/src/client/dist/spa/assets/{python-Cj54W2Tg.js → python-D5ykDuc8.js} +1 -1
- package/src/client/dist/spa/assets/{razor-D3gJxoX_.js → razor-CStvtec5.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-DxEHr3lW.js → render-chat-markdown-BywKNkXe.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-B6S4PLWH.js → tsMode-WEgYYKFt.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-Ca8AEX3t.js → typescript-BzFHuirT.js} +1 -1
- package/src/client/dist/spa/assets/{use-onboarding-CNeLPDtv.js → use-onboarding-C98jCHZu.js} +1 -1
- package/src/client/dist/spa/assets/{xml-CsKo4k8C.js → xml-NuCdCQMI.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-X5yKmi6z.js → yaml-CkTTgcUh.js} +1 -1
- package/src/client/dist/spa/index.html +2 -2
- package/src/mcp-server/kobo-tasks-handlers.ts +56 -5
- package/src/mcp-server/kobo-tasks-server.ts +8 -19
- package/src/client/dist/spa/assets/ActivityFeed-DU6lDEP0.js +0 -8
- package/src/client/dist/spa/assets/DiffViewer-DTdDcKZC.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-m801GPfI.js +0 -7
- package/src/client/dist/spa/assets/MainLayout-oJdQ-QKM.js +0 -37
- package/src/client/dist/spa/assets/SettingsPage-B59LoCos.js +0 -9
- package/src/client/dist/spa/assets/WorkspacePage-Bj1PJSWT.js +0 -4
- package/src/client/dist/spa/assets/i18n-D1I-Us2H.js +0 -1
- 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 += `-
|
|
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
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
const
|
|
1928
|
-
const
|
|
1929
|
-
|
|
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({
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
2417
|
-
|
|
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
|
|
2467
|
-
|
|
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
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
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: `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` / `
|
|
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-
|
|
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.',
|