@loicngr/kobo 1.2.0 → 1.4.0
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 +14 -0
- package/dist/mcp-server/kobo-tasks-server.js +2 -0
- package/dist/server/db/index.js +1 -0
- package/dist/server/db/migrations.js +72 -10
- package/dist/server/db/schema.js +5 -0
- package/dist/server/index.js +63 -9
- package/dist/server/routes/workspaces.js +208 -52
- package/dist/server/services/agent-manager.js +101 -9
- package/dist/server/services/notion-service.js +6 -3
- package/dist/server/services/pr-watcher-service.js +82 -0
- package/dist/server/services/settings-service.js +41 -22
- package/dist/server/services/websocket-service.js +41 -4
- package/dist/server/services/workspace-service.js +25 -2
- package/dist/server/utils/git-ops.js +200 -4
- package/dist/server/utils/paths.js +13 -0
- package/dist/server/utils/process-tracker.js +0 -4
- package/package.json +4 -3
- package/src/client/dist/spa/assets/ActivityFeed-Dxuw_8et.js +60 -0
- package/src/client/dist/spa/assets/ActivityFeed-OvgJQL4-.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-CTFi3DpD.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-DV9gt8DT.js +2 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
- package/src/client/dist/spa/assets/MainLayout-BxqZy-kp.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +1 -0
- package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +1 -0
- package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-sghN-B7_.js +1 -0
- package/src/client/dist/spa/assets/QPage-DL4rY7LD.js +1 -0
- package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-50Nqrcsk.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-DV5avRbc.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-L46GJjcy.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +1 -0
- package/src/client/dist/spa/assets/abap-Co3wj02O.js +1 -0
- package/src/client/dist/spa/assets/apex-CUKwGs62.js +1 -0
- package/src/client/dist/spa/assets/azcli-DMImymmY.js +1 -0
- package/src/client/dist/spa/assets/bat--P_y70-E.js +1 -0
- package/src/client/dist/spa/assets/bicep-C3w6oSfK.js +2 -0
- package/src/client/dist/spa/assets/cameligo-D9NSR4Rj.js +1 -0
- package/src/client/dist/spa/assets/clojure-BMcQme0t.js +1 -0
- package/src/client/dist/spa/assets/codicon-CgENjH2v.ttf +0 -0
- package/src/client/dist/spa/assets/coffee-BbMZaWx7.js +1 -0
- package/src/client/dist/spa/assets/cpp-CbrtEGgw.js +1 -0
- package/src/client/dist/spa/assets/csharp-Bc0fjUxA.js +1 -0
- package/src/client/dist/spa/assets/csp-DmbXuMT0.js +1 -0
- package/src/client/dist/spa/assets/css-gdwCt5by.js +3 -0
- package/src/client/dist/spa/assets/css.worker-D1piIYC4.js +102 -0
- package/src/client/dist/spa/assets/cssMode-DO8hqIpD.js +4 -0
- package/src/client/dist/spa/assets/cypher-ocmmfoQr.js +1 -0
- package/src/client/dist/spa/assets/dart-DbZ5eklb.js +1 -0
- package/src/client/dist/spa/assets/dockerfile-BLaMayDc.js +1 -0
- package/src/client/dist/spa/assets/ecl-LxXpHirr.js +1 -0
- package/src/client/dist/spa/assets/editor-COGk2gAX.css +1 -0
- package/src/client/dist/spa/assets/editor-CS3NEPi9.css +1 -0
- package/src/client/dist/spa/assets/editor.api-BZP41lht.js +818 -0
- package/src/client/dist/spa/assets/editor.main-BOjf9Jyl.js +53 -0
- package/src/client/dist/spa/assets/editor.worker-CJ9iTmkr.js +26 -0
- package/src/client/dist/spa/assets/elixir-C_geKt5o.js +1 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
- package/src/client/dist/spa/assets/flow9-DE2fI2ca.js +1 -0
- package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +1 -0
- package/src/client/dist/spa/assets/freemarker2-QAd0phKD.js +3 -0
- package/src/client/dist/spa/assets/fsharp-CJD6fImD.js +1 -0
- package/src/client/dist/spa/assets/go-jUCqQ7bD.js +1 -0
- package/src/client/dist/spa/assets/graphql-rw7g9h7D.js +1 -0
- package/src/client/dist/spa/assets/handlebars-D40ZA-yu.js +1 -0
- package/src/client/dist/spa/assets/hcl-BKX27Mn7.js +1 -0
- package/src/client/dist/spa/assets/html-Bzo97Bk0.js +1 -0
- package/src/client/dist/spa/assets/html.worker-C4q4XMPn.js +509 -0
- package/src/client/dist/spa/assets/htmlMode-7HShfg96.js +4 -0
- package/src/client/dist/spa/assets/i18n-BiMAFoN_.js +1 -0
- package/src/client/dist/spa/assets/index-CaOiQq0z.js +5 -0
- package/src/client/dist/spa/assets/{index-BThMCiY7.css → index-eX_lKHSg.css} +1 -1
- package/src/client/dist/spa/assets/ini-CrXjga2H.js +1 -0
- package/src/client/dist/spa/assets/java-D4jksGBb.js +1 -0
- package/src/client/dist/spa/assets/javascript-DpFlF6yx.js +1 -0
- package/src/client/dist/spa/assets/json.worker-C9p7xCYk.js +65 -0
- package/src/client/dist/spa/assets/jsonMode-DxEb1VXU.js +10 -0
- package/src/client/dist/spa/assets/julia-CbWxfkeS.js +1 -0
- package/src/client/dist/spa/assets/kotlin-B26Yx80V.js +1 -0
- package/src/client/dist/spa/assets/less-DFzn-zC9.js +2 -0
- package/src/client/dist/spa/assets/lexon-C-w-W8Yv.js +1 -0
- package/src/client/dist/spa/assets/liquid-IpMvWkVS.js +1 -0
- package/src/client/dist/spa/assets/lua-CHuE_HoG.js +1 -0
- package/src/client/dist/spa/assets/m3-DEFZN2qS.js +1 -0
- package/src/client/dist/spa/assets/markdown-Cbt4TlFt.js +1 -0
- package/src/client/dist/spa/assets/mdx-BM5S9XtA.js +1 -0
- package/src/client/dist/spa/assets/mips-C6m4XECw.js +1 -0
- package/src/client/dist/spa/assets/monaco.contribution-Cpcgk43V.js +2 -0
- package/src/client/dist/spa/assets/msdax-un0CFb_S.js +1 -0
- package/src/client/dist/spa/assets/mysql-CuAPeiOV.js +1 -0
- package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +1 -0
- package/src/client/dist/spa/assets/objective-c-DLVMdxAC.js +1 -0
- package/src/client/dist/spa/assets/pascal-BGCThuPY.js +1 -0
- package/src/client/dist/spa/assets/pascaligo-DfxSVpdo.js +1 -0
- package/src/client/dist/spa/assets/perl-BOE6y94t.js +1 -0
- package/src/client/dist/spa/assets/pgsql-Dn7JkY4F.js +1 -0
- package/src/client/dist/spa/assets/php-r1gD0KyT.js +1 -0
- package/src/client/dist/spa/assets/pla-CgXknhb0.js +1 -0
- package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +1 -0
- package/src/client/dist/spa/assets/postiats-CsIEtnRB.js +1 -0
- package/src/client/dist/spa/assets/powerquery-yNJCmC_6.js +1 -0
- package/src/client/dist/spa/assets/powershell-CQcz1SqH.js +1 -0
- package/src/client/dist/spa/assets/protobuf-BmC34uvO.js +2 -0
- package/src/client/dist/spa/assets/pug-C20znvWM.js +1 -0
- package/src/client/dist/spa/assets/python-CBiKH2mZ.js +1 -0
- package/src/client/dist/spa/assets/qsharp-B7bnARMS.js +1 -0
- package/src/client/dist/spa/assets/r-ClvcLdqC.js +1 -0
- package/src/client/dist/spa/assets/razor-BV3hIY51.js +1 -0
- package/src/client/dist/spa/assets/redis-DCyda7_S.js +1 -0
- package/src/client/dist/spa/assets/redshift-BtWDr4pb.js +1 -0
- package/src/client/dist/spa/assets/restructuredtext-CLcnlkhl.js +1 -0
- package/src/client/dist/spa/assets/ruby-DY0SOSSZ.js +1 -0
- package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +1 -0
- package/src/client/dist/spa/assets/rust-JQd-fJZI.js +1 -0
- package/src/client/dist/spa/assets/sb-BV2j8yFF.js +1 -0
- package/src/client/dist/spa/assets/scala-DwbnREDs.js +1 -0
- package/src/client/dist/spa/assets/scheme-CrtA-vei.js +1 -0
- package/src/client/dist/spa/assets/scss-VxQz3zmI.js +3 -0
- package/src/client/dist/spa/assets/shell-CP9faqFI.js +1 -0
- package/src/client/dist/spa/assets/solidity-9IIb0b89.js +1 -0
- package/src/client/dist/spa/assets/sophia-D2LQU2AD.js +1 -0
- package/src/client/dist/spa/assets/sparql-DONCa5dy.js +1 -0
- package/src/client/dist/spa/assets/sql-DaAAHGEt.js +1 -0
- package/src/client/dist/spa/assets/st-CRY2V-j3.js +1 -0
- package/src/client/dist/spa/assets/swift-BlKbfloF.js +1 -0
- package/src/client/dist/spa/assets/systemverilog-B_h9Q_T_.js +1 -0
- package/src/client/dist/spa/assets/tcl-C4wN3A6M.js +1 -0
- package/src/client/dist/spa/assets/ts.worker-Cj3zTgVE.js +51353 -0
- package/src/client/dist/spa/assets/tsMode-DUqyritq.js +11 -0
- package/src/client/dist/spa/assets/twig-DDdaBLC9.js +1 -0
- package/src/client/dist/spa/assets/typescript-BvZDZzaz.js +1 -0
- package/src/client/dist/spa/assets/typespec-Dc1ipt8A.js +1 -0
- package/src/client/dist/spa/assets/use-checkbox-Dwcwf6Nj.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-DMvrrord.js +1 -0
- package/src/client/dist/spa/assets/vb-C4BXIvrh.js +1 -0
- package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +3 -0
- package/src/client/dist/spa/assets/wgsl-XVg3Pi-r.js +298 -0
- package/src/client/dist/spa/assets/xml-BgsHEniP.js +1 -0
- package/src/client/dist/spa/assets/yaml-C-Mr6Xov.js +1 -0
- package/src/client/dist/spa/index.html +5 -3
- package/src/mcp-server/kobo-tasks-server.ts +2 -0
- package/src/client/dist/spa/assets/ActivityFeed-CPfYmybV.js +0 -60
- package/src/client/dist/spa/assets/ActivityFeed-DBljh9rq.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-C_c3Gr0F.js +0 -2
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
- package/src/client/dist/spa/assets/MainLayout-BMxEROm4.css +0 -1
- package/src/client/dist/spa/assets/MainLayout-QtbVbbnd.js +0 -1
- package/src/client/dist/spa/assets/QBadge-CNojh9Rl.js +0 -1
- package/src/client/dist/spa/assets/QDialog-DgR7t6Vf.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-VVjlYOIT.js +0 -1
- package/src/client/dist/spa/assets/QPage-DX4g-Dpe.js +0 -1
- package/src/client/dist/spa/assets/QSpinnerDots-DeCf9Lr-.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-DKYJ8kVW.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-DjWKsLC-.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-Yv31Z9aG.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DkM58caD.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-EAh91w9s.js +0 -2
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-C6NdfBK4.js +0 -1
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/src/client/dist/spa/assets/index-C4WDJfjD.js +0 -5
- package/src/client/dist/spa/assets/nodes-irfhA8FK.js +0 -1
- package/src/client/dist/spa/assets/use-checkbox-BS9cbwg_.js +0 -1
- package/src/client/dist/spa/assets/use-quasar-CH0pSHUf.js +0 -1
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFileCb);
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
2
6
|
import { Hono } from 'hono';
|
|
7
|
+
import { getDb } from '../db/index.js';
|
|
3
8
|
import * as agentManager from '../services/agent-manager.js';
|
|
4
9
|
import * as devServerService from '../services/dev-server-service.js';
|
|
5
10
|
import * as notionService from '../services/notion-service.js';
|
|
11
|
+
import { renderPrTemplate } from '../services/pr-template-service.js';
|
|
6
12
|
import * as settingsService from '../services/settings-service.js';
|
|
7
13
|
import * as wsService from '../services/websocket-service.js';
|
|
8
14
|
import * as workspaceService from '../services/workspace-service.js';
|
|
@@ -107,15 +113,34 @@ app.post('/', async (c) => {
|
|
|
107
113
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
108
114
|
return c.json({ error: `Failed to create worktree: ${message}` }, 500);
|
|
109
115
|
}
|
|
110
|
-
// 4b.
|
|
116
|
+
// 4b. Ensure Kobo-generated files inside .ai/ are gitignored (the .ai/ dir
|
|
117
|
+
// itself may contain project files that SHOULD be committed).
|
|
118
|
+
try {
|
|
119
|
+
const gitignorePath = path.join(worktreePath, '.gitignore');
|
|
120
|
+
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
|
|
121
|
+
const lines = existing.split('\n').map((l) => l.trim());
|
|
122
|
+
const toAdd = [];
|
|
123
|
+
if (!lines.includes('.ai/git-conventions.md'))
|
|
124
|
+
toAdd.push('.ai/git-conventions.md');
|
|
125
|
+
if (!lines.includes('.ai/thoughts/'))
|
|
126
|
+
toAdd.push('.ai/thoughts/');
|
|
127
|
+
if (!lines.includes('.ai/images/'))
|
|
128
|
+
toAdd.push('.ai/images/');
|
|
129
|
+
if (toAdd.length > 0) {
|
|
130
|
+
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
131
|
+
fs.appendFileSync(gitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error('[workspaces] Failed to update .gitignore:', err);
|
|
136
|
+
}
|
|
137
|
+
// 4c. Write git conventions to the worktree if configured
|
|
111
138
|
const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
|
|
112
139
|
if (effectiveSettings.gitConventions) {
|
|
113
140
|
try {
|
|
114
|
-
const
|
|
115
|
-
const path = await import('node:path');
|
|
116
|
-
const aiDir = path.default.join(worktreePath, '.ai');
|
|
141
|
+
const aiDir = path.join(worktreePath, '.ai');
|
|
117
142
|
fs.mkdirSync(aiDir, { recursive: true });
|
|
118
|
-
const conventionsPath = path.
|
|
143
|
+
const conventionsPath = path.join(aiDir, 'git-conventions.md');
|
|
119
144
|
fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
|
|
120
145
|
}
|
|
121
146
|
catch (err) {
|
|
@@ -126,16 +151,14 @@ app.post('/', async (c) => {
|
|
|
126
151
|
let notionFilePath = null;
|
|
127
152
|
if (notionContent && body.notionUrl) {
|
|
128
153
|
try {
|
|
129
|
-
const
|
|
130
|
-
const path = await import('node:path');
|
|
131
|
-
const thoughtsDir = path.default.join(worktreePath, '.ai', 'thoughts');
|
|
154
|
+
const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
|
|
132
155
|
fs.mkdirSync(thoughtsDir, { recursive: true });
|
|
133
156
|
// Derive filename from title (TK-XXX pattern or slug)
|
|
134
157
|
const tkMatch = workspace.name.match(/TK-\d+/i);
|
|
135
158
|
const filename = tkMatch
|
|
136
159
|
? `${tkMatch[0]}.md`
|
|
137
160
|
: `PAGE-${notionService.parseNotionUrl(body.notionUrl).replace(/-/g, '')}.md`;
|
|
138
|
-
notionFilePath = path.
|
|
161
|
+
notionFilePath = path.join(thoughtsDir, filename);
|
|
139
162
|
const today = new Date().toISOString().split('T')[0];
|
|
140
163
|
let md = `# ${workspace.name}\n\n`;
|
|
141
164
|
md += `## Source\n\n`;
|
|
@@ -196,8 +219,7 @@ app.post('/', async (c) => {
|
|
|
196
219
|
brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
|
|
197
220
|
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
|
|
198
221
|
// Persist the initial prompt in the feed so it's visible in the chat
|
|
199
|
-
|
|
200
|
-
emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
|
|
222
|
+
wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
|
|
201
223
|
try {
|
|
202
224
|
agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
|
|
203
225
|
}
|
|
@@ -246,7 +268,7 @@ app.post('/:id/refresh-notion', async (c) => {
|
|
|
246
268
|
return c.json({ error: 'No Notion URL configured' }, 400);
|
|
247
269
|
const notionContent = await notionService.extractNotionPage(workspace.notionUrl);
|
|
248
270
|
// Delete existing tasks and recreate from Notion
|
|
249
|
-
const db =
|
|
271
|
+
const db = getDb();
|
|
250
272
|
db.prepare('DELETE FROM tasks WHERE workspace_id = ?').run(id);
|
|
251
273
|
let sortOrder = 0;
|
|
252
274
|
for (const todo of notionContent.todos) {
|
|
@@ -304,7 +326,16 @@ app.post('/:id/tasks', async (c) => {
|
|
|
304
326
|
// PATCH /api/workspaces/:id/tasks/:taskId — update task status and/or title
|
|
305
327
|
app.patch('/:id/tasks/:taskId', async (c) => {
|
|
306
328
|
try {
|
|
329
|
+
const id = c.req.param('id');
|
|
307
330
|
const taskId = c.req.param('taskId');
|
|
331
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
332
|
+
if (!workspace) {
|
|
333
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
334
|
+
}
|
|
335
|
+
const task = workspaceService.getTask(taskId, id);
|
|
336
|
+
if (!task) {
|
|
337
|
+
return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
|
|
338
|
+
}
|
|
308
339
|
const body = await c.req.json();
|
|
309
340
|
if (body.status === undefined && body.title === undefined) {
|
|
310
341
|
return c.json({ error: 'At least one of status or title is required' }, 400);
|
|
@@ -338,6 +369,10 @@ app.delete('/:id/tasks/:taskId', (c) => {
|
|
|
338
369
|
if (!workspace) {
|
|
339
370
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
340
371
|
}
|
|
372
|
+
const task = workspaceService.getTask(taskId, id);
|
|
373
|
+
if (!task) {
|
|
374
|
+
return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
|
|
375
|
+
}
|
|
341
376
|
workspaceService.deleteTask(taskId);
|
|
342
377
|
return new Response(null, { status: 204 });
|
|
343
378
|
}
|
|
@@ -346,6 +381,24 @@ app.delete('/:id/tasks/:taskId', (c) => {
|
|
|
346
381
|
return c.json({ error: message }, 500);
|
|
347
382
|
}
|
|
348
383
|
});
|
|
384
|
+
// POST /api/workspaces/:id/tasks/notify-updated — broadcast generic task list change
|
|
385
|
+
// Must be declared BEFORE /:id/tasks/:taskId/notify-done so Hono doesn't capture
|
|
386
|
+
// "notify-updated" as a :taskId parameter.
|
|
387
|
+
app.post('/:id/tasks/notify-updated', (c) => {
|
|
388
|
+
try {
|
|
389
|
+
const id = c.req.param('id');
|
|
390
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
391
|
+
if (!workspace) {
|
|
392
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
393
|
+
}
|
|
394
|
+
wsService.emit(id, 'task:updated', {});
|
|
395
|
+
return new Response(null, { status: 204 });
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
399
|
+
return c.json({ error: message }, 500);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
349
402
|
// POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
|
|
350
403
|
app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
351
404
|
try {
|
|
@@ -363,16 +416,66 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
|
363
416
|
return c.json({ error: message }, 500);
|
|
364
417
|
}
|
|
365
418
|
});
|
|
366
|
-
//
|
|
367
|
-
app.
|
|
419
|
+
// GET /api/workspaces/:id/events — paginated event history (must be before GET /:id for route ordering)
|
|
420
|
+
app.get('/:id/events', (c) => {
|
|
368
421
|
try {
|
|
369
422
|
const id = c.req.param('id');
|
|
370
423
|
const workspace = workspaceService.getWorkspace(id);
|
|
371
424
|
if (!workspace) {
|
|
372
425
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
373
426
|
}
|
|
374
|
-
|
|
375
|
-
|
|
427
|
+
const before = c.req.query('before'); // event ID cursor
|
|
428
|
+
const limit = Math.min(parseInt(c.req.query('limit') ?? '100', 10) || 100, 500);
|
|
429
|
+
const db = getDb();
|
|
430
|
+
let rows;
|
|
431
|
+
if (before) {
|
|
432
|
+
// Get the rowid of the cursor event
|
|
433
|
+
const cursorRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(before);
|
|
434
|
+
if (!cursorRow) {
|
|
435
|
+
return c.json({ events: [], hasMore: false });
|
|
436
|
+
}
|
|
437
|
+
rows = db
|
|
438
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?')
|
|
439
|
+
.all(id, cursorRow.rowid, limit);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
// No cursor — return the oldest events
|
|
443
|
+
rows = db
|
|
444
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
|
|
445
|
+
.all(id, limit);
|
|
446
|
+
}
|
|
447
|
+
// Reverse to chronological order (we queried DESC for "before" pagination)
|
|
448
|
+
if (before)
|
|
449
|
+
rows.reverse();
|
|
450
|
+
const events = rows.map((row) => {
|
|
451
|
+
let parsedPayload;
|
|
452
|
+
try {
|
|
453
|
+
parsedPayload = JSON.parse(row.payload);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
parsedPayload = row.payload;
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
id: row.id,
|
|
460
|
+
workspaceId: row.workspace_id,
|
|
461
|
+
type: row.type,
|
|
462
|
+
payload: parsedPayload,
|
|
463
|
+
sessionId: row.session_id,
|
|
464
|
+
createdAt: row.created_at,
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
// Check if there are more older events beyond what we returned
|
|
468
|
+
let hasMore = false;
|
|
469
|
+
if (before && rows.length > 0) {
|
|
470
|
+
const firstRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(rows[0].id);
|
|
471
|
+
if (firstRow) {
|
|
472
|
+
const older = db
|
|
473
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
|
|
474
|
+
.get(id, firstRow.rowid);
|
|
475
|
+
hasMore = older.c > 0;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return c.json({ events, hasMore });
|
|
376
479
|
}
|
|
377
480
|
catch (err) {
|
|
378
481
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -404,7 +507,7 @@ app.get('/:id', (c) => {
|
|
|
404
507
|
return c.json({ error: message }, 500);
|
|
405
508
|
}
|
|
406
509
|
});
|
|
407
|
-
// PATCH /api/workspaces/:id — update workspace fields (status, model)
|
|
510
|
+
// PATCH /api/workspaces/:id — update workspace fields (status, model, permissionMode)
|
|
408
511
|
app.patch('/:id', async (c) => {
|
|
409
512
|
try {
|
|
410
513
|
const id = c.req.param('id');
|
|
@@ -417,11 +520,18 @@ app.patch('/:id', async (c) => {
|
|
|
417
520
|
if (body.model !== undefined) {
|
|
418
521
|
updated = workspaceService.updateWorkspaceModel(id, body.model);
|
|
419
522
|
}
|
|
523
|
+
if (body.permissionMode !== undefined) {
|
|
524
|
+
const validModes = ['auto-accept', 'plan'];
|
|
525
|
+
if (!validModes.includes(body.permissionMode)) {
|
|
526
|
+
return c.json({ error: `Invalid permission mode. Must be one of: ${validModes.join(', ')}` }, 400);
|
|
527
|
+
}
|
|
528
|
+
updated = workspaceService.updateWorkspacePermissionMode(id, body.permissionMode);
|
|
529
|
+
}
|
|
420
530
|
if (body.status) {
|
|
421
531
|
updated = workspaceService.updateWorkspaceStatus(id, body.status);
|
|
422
532
|
}
|
|
423
|
-
if (!body.status && body.model === undefined) {
|
|
424
|
-
return c.json({ error: 'Missing field: status or
|
|
533
|
+
if (!body.status && body.model === undefined && body.permissionMode === undefined) {
|
|
534
|
+
return c.json({ error: 'Missing field: status, model, or permissionMode' }, 400);
|
|
425
535
|
}
|
|
426
536
|
return c.json(updated);
|
|
427
537
|
}
|
|
@@ -565,7 +675,7 @@ app.post('/:id/start', async (c) => {
|
|
|
565
675
|
// Agent may not be running — ignore
|
|
566
676
|
}
|
|
567
677
|
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
568
|
-
agentManager.startAgent(id, worktreePath, prompt, workspace.model);
|
|
678
|
+
agentManager.startAgent(id, worktreePath, prompt, workspace.model, false, workspace.permissionMode);
|
|
569
679
|
workspaceService.updateWorkspaceStatus(id, 'executing');
|
|
570
680
|
return c.json({ status: 'started' });
|
|
571
681
|
}
|
|
@@ -582,17 +692,21 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
582
692
|
if (!workspace) {
|
|
583
693
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
584
694
|
}
|
|
585
|
-
const
|
|
586
|
-
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
695
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
587
696
|
const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
588
697
|
const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
589
|
-
const
|
|
698
|
+
const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
|
|
699
|
+
const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath);
|
|
700
|
+
const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
|
|
590
701
|
return c.json({
|
|
591
702
|
commitCount,
|
|
592
703
|
filesChanged: diffStats.filesChanged,
|
|
593
704
|
insertions: diffStats.insertions,
|
|
594
705
|
deletions: diffStats.deletions,
|
|
595
|
-
prUrl,
|
|
706
|
+
prUrl: pr?.url ?? null,
|
|
707
|
+
prState: pr?.state ?? null,
|
|
708
|
+
unpushedCount,
|
|
709
|
+
workingTree,
|
|
596
710
|
});
|
|
597
711
|
}
|
|
598
712
|
catch (err) {
|
|
@@ -600,6 +714,49 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
600
714
|
return c.json({ error: message }, 500);
|
|
601
715
|
}
|
|
602
716
|
});
|
|
717
|
+
// GET /api/workspaces/:id/diff — list changed files
|
|
718
|
+
app.get('/:id/diff', (c) => {
|
|
719
|
+
try {
|
|
720
|
+
const id = c.req.param('id');
|
|
721
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
722
|
+
if (!workspace) {
|
|
723
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
724
|
+
}
|
|
725
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
726
|
+
const files = gitOps.getChangedFiles(worktreePath, workspace.sourceBranch);
|
|
727
|
+
return c.json({
|
|
728
|
+
files,
|
|
729
|
+
sourceBranch: workspace.sourceBranch,
|
|
730
|
+
workingBranch: workspace.workingBranch,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
735
|
+
return c.json({ error: message }, 500);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
// GET /api/workspaces/:id/diff/:filePath — get original and modified content for a file
|
|
739
|
+
app.get('/:id/diff-file', (c) => {
|
|
740
|
+
try {
|
|
741
|
+
const id = c.req.param('id');
|
|
742
|
+
const filePath = c.req.query('path');
|
|
743
|
+
if (!filePath) {
|
|
744
|
+
return c.json({ error: 'Missing path query parameter' }, 400);
|
|
745
|
+
}
|
|
746
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
747
|
+
if (!workspace) {
|
|
748
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
749
|
+
}
|
|
750
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
751
|
+
const original = gitOps.getFileAtRef(worktreePath, workspace.sourceBranch, filePath);
|
|
752
|
+
const modified = gitOps.getFileContent(worktreePath, filePath);
|
|
753
|
+
return c.json({ original: original ?? '', modified: modified ?? '', filePath });
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
757
|
+
return c.json({ error: message }, 500);
|
|
758
|
+
}
|
|
759
|
+
});
|
|
603
760
|
// POST /api/workspaces/:id/push — push working branch to origin
|
|
604
761
|
app.post('/:id/push', async (c) => {
|
|
605
762
|
try {
|
|
@@ -608,8 +765,7 @@ app.post('/:id/push', async (c) => {
|
|
|
608
765
|
if (!workspace) {
|
|
609
766
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
610
767
|
}
|
|
611
|
-
const
|
|
612
|
-
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
768
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
613
769
|
try {
|
|
614
770
|
gitOps.pushBranch(worktreePath, workspace.workingBranch);
|
|
615
771
|
}
|
|
@@ -618,10 +774,9 @@ app.post('/:id/push', async (c) => {
|
|
|
618
774
|
return c.json({ error: message }, 500);
|
|
619
775
|
}
|
|
620
776
|
// Emit a trace into the chat feed so the user sees the action
|
|
621
|
-
const { emit } = await import('../services/websocket-service.js');
|
|
622
777
|
const session = workspaceService.getLatestSession(id);
|
|
623
778
|
const sessionId = session?.claudeSessionId ?? undefined;
|
|
624
|
-
emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, sessionId);
|
|
779
|
+
wsService.emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, sessionId);
|
|
625
780
|
return c.json({ ok: true, branch: workspace.workingBranch });
|
|
626
781
|
}
|
|
627
782
|
catch (err) {
|
|
@@ -637,15 +792,14 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
637
792
|
if (!workspace) {
|
|
638
793
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
639
794
|
}
|
|
640
|
-
const
|
|
641
|
-
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
795
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
642
796
|
// 1. Check branch is on remote
|
|
643
797
|
let lsRemoteOut = '';
|
|
644
798
|
try {
|
|
645
|
-
const
|
|
799
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
|
|
646
800
|
cwd: worktreePath,
|
|
647
801
|
});
|
|
648
|
-
lsRemoteOut =
|
|
802
|
+
lsRemoteOut = stdout;
|
|
649
803
|
}
|
|
650
804
|
catch {
|
|
651
805
|
lsRemoteOut = '';
|
|
@@ -655,8 +809,8 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
655
809
|
}
|
|
656
810
|
// 2. Check all local commits are pushed
|
|
657
811
|
try {
|
|
658
|
-
const
|
|
659
|
-
const countStr =
|
|
812
|
+
const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
|
|
813
|
+
const countStr = stdout.trim();
|
|
660
814
|
const count = parseInt(countStr, 10) || 0;
|
|
661
815
|
if (count > 0) {
|
|
662
816
|
return c.json({ error: 'Local commits not pushed', code: 'unpushed_commits' }, 409);
|
|
@@ -675,7 +829,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
675
829
|
let ghOutput;
|
|
676
830
|
try {
|
|
677
831
|
const placeholderBody = 'Automated PR — description will be updated by the agent.';
|
|
678
|
-
const
|
|
832
|
+
const { stdout } = await execFileAsync('gh', [
|
|
679
833
|
'pr',
|
|
680
834
|
'create',
|
|
681
835
|
'--base',
|
|
@@ -687,7 +841,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
687
841
|
'--body',
|
|
688
842
|
placeholderBody,
|
|
689
843
|
], { cwd: worktreePath });
|
|
690
|
-
ghOutput =
|
|
844
|
+
ghOutput = stdout;
|
|
691
845
|
}
|
|
692
846
|
catch (err) {
|
|
693
847
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -708,7 +862,6 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
708
862
|
return c.json({ ok: true, prNumber, prUrl, messageSent: false });
|
|
709
863
|
}
|
|
710
864
|
// 6. Build context and render the template
|
|
711
|
-
const { renderPrTemplate } = await import('../services/pr-template-service.js');
|
|
712
865
|
const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
713
866
|
const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
714
867
|
const tasks = workspaceService.listTasks(workspace.id);
|
|
@@ -721,26 +874,29 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
721
874
|
tasks,
|
|
722
875
|
});
|
|
723
876
|
// 7. Emit user:message into the chat feed
|
|
724
|
-
const { emit } = await import('../services/websocket-service.js');
|
|
725
877
|
const session = workspaceService.getLatestSession(workspace.id);
|
|
726
878
|
const sessionId = session?.claudeSessionId ?? undefined;
|
|
727
|
-
emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
728
|
-
// 8. Send to the running agent
|
|
879
|
+
wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
880
|
+
// 8. Send to the running agent, or resume the agent with the PR prompt
|
|
881
|
+
let messageSent = false;
|
|
729
882
|
try {
|
|
730
883
|
agentManager.sendMessage(workspace.id, rendered);
|
|
731
|
-
|
|
884
|
+
messageSent = true;
|
|
732
885
|
}
|
|
733
|
-
catch
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
886
|
+
catch {
|
|
887
|
+
// Agent not running — resume it with the PR prompt
|
|
888
|
+
try {
|
|
889
|
+
const worktreePathForResume = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
890
|
+
agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.permissionMode);
|
|
891
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
|
|
892
|
+
messageSent = true;
|
|
893
|
+
}
|
|
894
|
+
catch (resumeErr) {
|
|
895
|
+
const resumeMsg = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
|
|
896
|
+
console.warn(`[workspaces] open-pr: PR created but agent resume failed: ${resumeMsg}`);
|
|
897
|
+
}
|
|
743
898
|
}
|
|
899
|
+
return c.json({ ok: true, prNumber, prUrl, messageSent });
|
|
744
900
|
}
|
|
745
901
|
catch (err) {
|
|
746
902
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -6,6 +6,7 @@ import { nanoid } from 'nanoid';
|
|
|
6
6
|
import { getDb } from '../db/index.js';
|
|
7
7
|
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../utils/paths.js';
|
|
8
8
|
import { registerProcess, unregisterProcess } from '../utils/process-tracker.js';
|
|
9
|
+
import { getEffectiveSettings } from './settings-service.js';
|
|
9
10
|
import { emit } from './websocket-service.js';
|
|
10
11
|
import { getWorkspace as getWs, listTasks, updateWorkspaceStatus } from './workspace-service.js';
|
|
11
12
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
@@ -35,8 +36,71 @@ const retryCounts = new Map();
|
|
|
35
36
|
const backoffTimers = new Map();
|
|
36
37
|
/** workspaceId -> pending SIGKILL timer */
|
|
37
38
|
const killTimers = new Map();
|
|
39
|
+
// ── Watchdog ──────────────────────────────────────────────────────────────────
|
|
40
|
+
// Periodically checks that tracked agent processes are still alive.
|
|
41
|
+
// If a process died without triggering the 'exit' handler (crash, OOM kill,
|
|
42
|
+
// etc.), the watchdog cleans up and updates the workspace status.
|
|
43
|
+
const WATCHDOG_INTERVAL_MS = 30_000;
|
|
44
|
+
let watchdogTimer = null;
|
|
45
|
+
function isProcessAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0); // signal 0 = existence check, doesn't actually kill
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function runWatchdog() {
|
|
55
|
+
for (const [workspaceId, agent] of agents) {
|
|
56
|
+
const pid = agent.process.pid;
|
|
57
|
+
if (pid === undefined || isProcessAlive(pid))
|
|
58
|
+
continue;
|
|
59
|
+
console.error(`[watchdog] Agent process for workspace '${workspaceId}' (PID ${pid}) is dead — cleaning up`);
|
|
60
|
+
// Close readline to release the stream
|
|
61
|
+
try {
|
|
62
|
+
agent.rl.close();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Ignore
|
|
66
|
+
}
|
|
67
|
+
unregisterProcess(workspaceId);
|
|
68
|
+
agents.delete(workspaceId);
|
|
69
|
+
retryCounts.delete(workspaceId);
|
|
70
|
+
// Update DB session
|
|
71
|
+
try {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), agent.agentSessionId);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error('[watchdog] Failed to update agent_sessions:', err);
|
|
77
|
+
}
|
|
78
|
+
// Update workspace status
|
|
79
|
+
try {
|
|
80
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Transition may not be valid — ignore
|
|
84
|
+
}
|
|
85
|
+
emit(workspaceId, 'agent:status', { status: 'error', message: 'Agent process died unexpectedly' }, agent.claudeSessionId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Start the watchdog (called once from server bootstrap). */
|
|
89
|
+
export function startWatchdog() {
|
|
90
|
+
if (watchdogTimer)
|
|
91
|
+
return;
|
|
92
|
+
watchdogTimer = setInterval(runWatchdog, WATCHDOG_INTERVAL_MS);
|
|
93
|
+
watchdogTimer.unref?.();
|
|
94
|
+
}
|
|
95
|
+
/** Stop the watchdog (for clean shutdown / tests). */
|
|
96
|
+
export function stopWatchdog() {
|
|
97
|
+
if (watchdogTimer) {
|
|
98
|
+
clearInterval(watchdogTimer);
|
|
99
|
+
watchdogTimer = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
38
102
|
// ── Start agent ────────────────────────────────────────────────────────────────
|
|
39
|
-
export function startAgent(workspaceId, workingDir, prompt, model, resume = false) {
|
|
103
|
+
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept') {
|
|
40
104
|
// Check if agent already running for this workspace
|
|
41
105
|
if (agents.has(workspaceId)) {
|
|
42
106
|
throw new Error(`Agent already running for workspace '${workspaceId}'`);
|
|
@@ -44,8 +108,18 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
44
108
|
const db = getDb();
|
|
45
109
|
let agentSessionId;
|
|
46
110
|
let resumedClaudeSessionId;
|
|
47
|
-
// Build CLI args
|
|
48
|
-
const
|
|
111
|
+
// Build CLI args — read dangerouslySkipPermissions from effective settings
|
|
112
|
+
const ws = getWs(workspaceId);
|
|
113
|
+
const effectiveSettings = ws ? getEffectiveSettings(ws.projectPath) : null;
|
|
114
|
+
const skipPermissions = effectiveSettings?.dangerouslySkipPermissions ?? true;
|
|
115
|
+
const args = ['--output-format', 'stream-json', '--verbose'];
|
|
116
|
+
if (skipPermissions) {
|
|
117
|
+
args.push('--dangerously-skip-permissions');
|
|
118
|
+
}
|
|
119
|
+
if (permissionMode === 'plan') {
|
|
120
|
+
// In plan mode, prepend read-only instructions to the prompt
|
|
121
|
+
prompt = `[PLAN MODE] You are in PLAN/READ-ONLY mode. You MUST NOT create, edit, write, or delete any files. Only use read-only tools (Read, Grep, Glob, LS, Bash for read-only commands). Analyze the codebase, plan your approach, and present your findings — but do NOT execute any changes.\n\n${prompt}`;
|
|
122
|
+
}
|
|
49
123
|
if (model && model !== 'auto') {
|
|
50
124
|
args.push('--model', model);
|
|
51
125
|
}
|
|
@@ -234,7 +308,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
234
308
|
const text = data.toString();
|
|
235
309
|
const lowerText = text.toLowerCase();
|
|
236
310
|
if (lowerText.includes('rate limit') || lowerText.includes('quota') || lowerText.includes('limit exceeded')) {
|
|
237
|
-
handleQuota(workspaceId,
|
|
311
|
+
handleQuota(workspaceId, agent.claudeSessionId);
|
|
238
312
|
}
|
|
239
313
|
// Also emit stderr for visibility
|
|
240
314
|
emit(workspaceId, 'agent:stderr', { content: text }, agent.claudeSessionId);
|
|
@@ -251,7 +325,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
251
325
|
// I3: Close readline interface to release the stream reference
|
|
252
326
|
agent.rl.close();
|
|
253
327
|
unregisterProcess(workspaceId);
|
|
254
|
-
|
|
328
|
+
// Only remove from the map if this exact agent instance is still current.
|
|
329
|
+
// stopAgent() eagerly removes the entry so startAgent() can proceed
|
|
330
|
+
// immediately; if a new agent was started in the meantime, we must not
|
|
331
|
+
// remove it.
|
|
332
|
+
if (agents.get(workspaceId) === agent) {
|
|
333
|
+
agents.delete(workspaceId);
|
|
334
|
+
}
|
|
255
335
|
// Clean up retry state and inactivity timer
|
|
256
336
|
retryCounts.delete(workspaceId);
|
|
257
337
|
// C2: Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
|
|
@@ -308,6 +388,10 @@ export function stopAgent(workspaceId) {
|
|
|
308
388
|
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
309
389
|
}
|
|
310
390
|
agent.status = 'stopping';
|
|
391
|
+
// Remove from the map immediately so startAgent() can be called right after
|
|
392
|
+
// without hitting "Agent already running". The exit handler checks identity
|
|
393
|
+
// before removing, so a new agent started in the meantime won't be affected.
|
|
394
|
+
agents.delete(workspaceId);
|
|
311
395
|
// Cancel any pending backoff timer
|
|
312
396
|
const timer = backoffTimers.get(workspaceId);
|
|
313
397
|
if (timer) {
|
|
@@ -330,8 +414,10 @@ export function stopAgent(workspaceId) {
|
|
|
330
414
|
}
|
|
331
415
|
// After 5s timeout, send SIGKILL if still running
|
|
332
416
|
const killTimer = setTimeout(() => {
|
|
333
|
-
// C2:
|
|
334
|
-
|
|
417
|
+
// C2: If a new agent has been started for this workspace in the meantime,
|
|
418
|
+
// don't kill the old process — it's handled by the new lifecycle.
|
|
419
|
+
const currentAgent = agents.get(workspaceId);
|
|
420
|
+
if (currentAgent && currentAgent !== agent) {
|
|
335
421
|
killTimers.delete(workspaceId);
|
|
336
422
|
return;
|
|
337
423
|
}
|
|
@@ -372,7 +458,7 @@ export function getAvailableSkills() {
|
|
|
372
458
|
return availableSkills;
|
|
373
459
|
}
|
|
374
460
|
// ── Quota handling ─────────────────────────────────────────────────────────────
|
|
375
|
-
function handleQuota(workspaceId,
|
|
461
|
+
function handleQuota(workspaceId, claudeSessionId) {
|
|
376
462
|
// Update workspace status
|
|
377
463
|
try {
|
|
378
464
|
updateWorkspaceStatus(workspaceId, 'quota');
|
|
@@ -397,8 +483,14 @@ function handleQuota(workspaceId, workingDir, claudeSessionId) {
|
|
|
397
483
|
backoffTimers.delete(workspaceId);
|
|
398
484
|
// Only restart if not already running or stopped
|
|
399
485
|
if (!agents.has(workspaceId)) {
|
|
486
|
+
// Re-read workspace from DB — it may have been deleted or archived during backoff
|
|
487
|
+
const freshWs = getWs(workspaceId);
|
|
488
|
+
if (!freshWs || freshWs.archivedAt !== null || freshWs.status !== 'quota') {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
400
491
|
try {
|
|
401
|
-
|
|
492
|
+
const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
|
|
493
|
+
startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
|
|
402
494
|
}
|
|
403
495
|
catch {
|
|
404
496
|
// Agent restart failed
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { getPackageVersion } from '../utils/paths.js';
|
|
3
4
|
// Gherkin keywords (French and English)
|
|
4
5
|
const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
|
|
5
6
|
// C2: rpcIdCounter encapsulated in a closure to avoid module-level mutable state
|
|
@@ -53,10 +54,11 @@ export async function callMcpTool(mcpProcess, toolName, args) {
|
|
|
53
54
|
return;
|
|
54
55
|
}
|
|
55
56
|
let buffer = '';
|
|
56
|
-
// C1: 30s timeout
|
|
57
|
+
// C1: 30s timeout — I7: kill the MCP process on timeout to avoid zombie
|
|
57
58
|
const timeout = setTimeout(() => {
|
|
58
59
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
59
60
|
mcpProcess.stdout?.removeListener('error', onError);
|
|
61
|
+
mcpProcess.kill();
|
|
60
62
|
reject(new Error(`callMcpTool('${toolName}') timed out after 30s`));
|
|
61
63
|
}, 30_000);
|
|
62
64
|
const onData = (chunk) => {
|
|
@@ -155,7 +157,7 @@ async function initializeMcp(mcpProcess) {
|
|
|
155
157
|
params: {
|
|
156
158
|
protocolVersion: '2024-11-05',
|
|
157
159
|
capabilities: {},
|
|
158
|
-
clientInfo: { name: 'kobo', version:
|
|
160
|
+
clientInfo: { name: 'kobo', version: getPackageVersion() },
|
|
159
161
|
},
|
|
160
162
|
});
|
|
161
163
|
await new Promise((resolve, reject) => {
|
|
@@ -164,9 +166,10 @@ async function initializeMcp(mcpProcess) {
|
|
|
164
166
|
return;
|
|
165
167
|
}
|
|
166
168
|
let buffer = '';
|
|
167
|
-
// C1: 10s timeout for initialization
|
|
169
|
+
// C1: 10s timeout for initialization — I7: kill the MCP process on timeout
|
|
168
170
|
const timeout = setTimeout(() => {
|
|
169
171
|
mcpProcess.stdout?.removeListener('data', onData);
|
|
172
|
+
mcpProcess.kill();
|
|
170
173
|
reject(new Error('initializeMcp timed out after 10s'));
|
|
171
174
|
}, 10_000);
|
|
172
175
|
const onData = (chunk) => {
|