@loicngr/kobo 1.4.5 → 1.4.7
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 +1 -1
- package/README.md +2 -2
- package/dist/mcp-server/kobo-tasks-handlers.js +12 -1
- package/dist/mcp-server/kobo-tasks-server.js +37 -2
- package/dist/server/db/index.js +5 -0
- package/dist/server/db/migrations.js +9 -0
- package/dist/server/db/schema.js +2 -0
- package/dist/server/index.js +17 -18
- package/dist/server/routes/dev-server.js +1 -0
- package/dist/server/routes/git.js +1 -0
- package/dist/server/routes/images.js +3 -0
- package/dist/server/routes/notion.js +1 -0
- package/dist/server/routes/settings.js +1 -0
- package/dist/server/routes/workspaces.js +166 -51
- package/dist/server/services/agent-manager.js +35 -9
- package/dist/server/services/image-service.js +2 -1
- package/dist/server/services/notion-service.js +14 -18
- package/dist/server/services/pr-watcher-service.js +2 -0
- package/dist/server/services/settings-service.js +33 -6
- package/dist/server/services/setup-script-service.js +1 -0
- package/dist/server/services/websocket-service.js +8 -9
- package/dist/server/services/workspace-service.js +33 -2
- package/dist/server/services/worktree-service.js +4 -2
- package/dist/server/utils/git-ops.js +19 -5
- package/dist/server/utils/process-tracker.js +7 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-BXrsWU-N.css +1 -0
- package/src/client/dist/spa/assets/ActivityFeed-Zg4aFWHr.js +68 -0
- package/src/client/dist/spa/assets/CreatePage-B4NEzk8h.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-Cb9tgQ57.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-CSwamnmH.js +2 -0
- package/src/client/dist/spa/assets/DiffViewer-DiHFLSk4.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-DYu8fNdb.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-To97f8bb.js +2 -0
- package/src/client/dist/spa/assets/QBadge-BBHYbjmH.js +1 -0
- package/src/client/dist/spa/assets/QCheckbox-C3OW_sBe.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem--CUnMqsJ.js +1 -0
- package/src/client/dist/spa/assets/QPage-DfVYLit9.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-DxFX2A_Y.js +1 -0
- package/src/client/dist/spa/assets/QTabPanels-CQiX55Zs.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-DDQMcKoX.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-CEJA3OTN.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-DHVfZP9g.js +1 -0
- package/src/client/dist/spa/assets/TouchPan-DmgViFM9.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BV77y-1V.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BfUdWLME.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-D5gezOWD.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-Dsa4Lydc.js → cssMode-4AF4Bwjq.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-qmVdKoc0.js → editor.api-Ctxw4rqS.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-D2fZAQLs.js → editor.main-BZM2ZVF-.js} +3 -3
- package/src/client/dist/spa/assets/format-DhM1gNfW.js +1 -0
- package/src/client/dist/spa/assets/formatters-BpjOVbqs.js +6 -0
- package/src/client/dist/spa/assets/{freemarker2-e-FYsZTq.js → freemarker2-nlLLg2wF.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CAwfoT2m.js → handlebars-D1p1nhG6.js} +1 -1
- package/src/client/dist/spa/assets/{html-BTRUpMfA.js → html-B6WFBXSs.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-mzgQeoHf.js → htmlMode-DLQXzxD7.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CrbCJQHz.js +1 -0
- package/src/client/dist/spa/assets/i18n-uUJIn7l1.js +1 -0
- package/src/client/dist/spa/assets/index-CkkvRhkB.js +5 -0
- package/src/client/dist/spa/assets/{javascript-DDeQuxhB.js → javascript-Cuk4WgU2.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-Ch5vu2Iw.js → jsonMode-CvIzW7YR.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-qVj3a9kh.js → liquid-BESgd7r_.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-Cb_a7RXe.js → mdx-CjeTpIfQ.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Rz70-mfd.js → monaco.contribution-BdsaXuzN.js} +2 -2
- package/src/client/dist/spa/assets/nodes-Bj1I9JfN.js +1 -0
- package/src/client/dist/spa/assets/{python-Cz0peIkX.js → python-DjonvvLh.js} +1 -1
- package/src/client/dist/spa/assets/{razor-CU8Xe-4p.js → razor-CYj_TCQ0.js} +1 -1
- package/src/client/dist/spa/assets/settings-DQXlzOR-.js +1 -0
- package/src/client/dist/spa/assets/touch-Dm5n4n0E.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-CP1svEaN.js → tsMode-C8aV_UsX.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-aPJGGIsI.js → typescript-Clp9vIWN.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-Cfet_Yar.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-Da_Py0Ib.js +1 -0
- package/src/client/dist/spa/assets/vue-i18n-nv59vAyH.js +3 -0
- package/src/client/dist/spa/assets/{xml-CZM1zQhV.js → xml-6gY0RxjJ.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-B8qPirw0.js → yaml-IQGDhYaJ.js} +1 -1
- package/src/client/dist/spa/index.html +5 -4
- package/src/client/dist/spa/notification.mp3 +0 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +21 -5
- package/src/mcp-server/kobo-tasks-server.ts +39 -2
- package/src/client/dist/spa/assets/ActivityFeed-Bx7maW4r.css +0 -1
- package/src/client/dist/spa/assets/ActivityFeed-DoQIjq5C.js +0 -60
- package/src/client/dist/spa/assets/CreatePage-6J0aDFtf.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-dvGJaxu-.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-DGzPKBi9.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +0 -1
- package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +0 -1
- package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-D5gm2xc8.js +0 -1
- package/src/client/dist/spa/assets/QPage-Bg3Rohl6.js +0 -1
- package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +0 -1
- package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BOkWnRl2.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-9gRnhdjv.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +0 -1
- package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +0 -1
- package/src/client/dist/spa/assets/i18n-B1eQvEGk.js +0 -1
- package/src/client/dist/spa/assets/index-C0u2YcnZ.js +0 -5
- package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +0 -1
- package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +0 -1
- package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +0 -1
- package/src/client/dist/spa/assets/use-checkbox-B8W131xl.js +0 -1
- package/src/client/dist/spa/assets/use-quasar-sc8fDqi0.js +0 -1
- package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +0 -3
- /package/src/client/dist/spa/assets/{abap-Co3wj02O.js → abap-DVDKJwpW.js} +0 -0
- /package/src/client/dist/spa/assets/{apex-CUKwGs62.js → apex-DjF5nFAs.js} +0 -0
- /package/src/client/dist/spa/assets/{azcli-DMImymmY.js → azcli-HEPMaiDV.js} +0 -0
- /package/src/client/dist/spa/assets/{bat--P_y70-E.js → bat-D6epFECU.js} +0 -0
- /package/src/client/dist/spa/assets/{bicep-C3w6oSfK.js → bicep-BspG10fo.js} +0 -0
- /package/src/client/dist/spa/assets/{cameligo-D9NSR4Rj.js → cameligo-DM9kSiq7.js} +0 -0
- /package/src/client/dist/spa/assets/{clojure-BMcQme0t.js → clojure-CZn9pzfW.js} +0 -0
- /package/src/client/dist/spa/assets/{coffee-BbMZaWx7.js → coffee-Bu_NglwI.js} +0 -0
- /package/src/client/dist/spa/assets/{cpp-CbrtEGgw.js → cpp-0KJLHDue.js} +0 -0
- /package/src/client/dist/spa/assets/{csharp-Bc0fjUxA.js → csharp-DNzOiVZu.js} +0 -0
- /package/src/client/dist/spa/assets/{csp-DmbXuMT0.js → csp-CUkzJlR0.js} +0 -0
- /package/src/client/dist/spa/assets/{css-gdwCt5by.js → css-Uav73wXk.js} +0 -0
- /package/src/client/dist/spa/assets/{cypher-ocmmfoQr.js → cypher-CP6eoQBS.js} +0 -0
- /package/src/client/dist/spa/assets/{dart-DbZ5eklb.js → dart-Bdl32fSd.js} +0 -0
- /package/src/client/dist/spa/assets/{dockerfile-BLaMayDc.js → dockerfile-BIRJ7ZNM.js} +0 -0
- /package/src/client/dist/spa/assets/{ecl-LxXpHirr.js → ecl-Di24nx2U.js} +0 -0
- /package/src/client/dist/spa/assets/{elixir-C_geKt5o.js → elixir-DzFG1iYF.js} +0 -0
- /package/src/client/dist/spa/assets/{flow9-DE2fI2ca.js → flow9-DNgNh1TU.js} +0 -0
- /package/src/client/dist/spa/assets/{fsharp-CJD6fImD.js → fsharp-CuO6_Oy9.js} +0 -0
- /package/src/client/dist/spa/assets/{go-jUCqQ7bD.js → go-Pj7ToRvM.js} +0 -0
- /package/src/client/dist/spa/assets/{graphql-rw7g9h7D.js → graphql-BoaedU4s.js} +0 -0
- /package/src/client/dist/spa/assets/{hcl-BKX27Mn7.js → hcl-olXtyJcc.js} +0 -0
- /package/src/client/dist/spa/assets/{ini-CrXjga2H.js → ini-BvGNUo-D.js} +0 -0
- /package/src/client/dist/spa/assets/{java-D4jksGBb.js → java-Z9-7Isu7.js} +0 -0
- /package/src/client/dist/spa/assets/{julia-CbWxfkeS.js → julia-Bdcb8Lkm.js} +0 -0
- /package/src/client/dist/spa/assets/{kotlin-B26Yx80V.js → kotlin-DR_I1UW_.js} +0 -0
- /package/src/client/dist/spa/assets/{less-DFzn-zC9.js → less-DZxcoWKd.js} +0 -0
- /package/src/client/dist/spa/assets/{lexon-C-w-W8Yv.js → lexon-s17AK9YH.js} +0 -0
- /package/src/client/dist/spa/assets/{lua-CHuE_HoG.js → lua-BzLfjAeg.js} +0 -0
- /package/src/client/dist/spa/assets/{m3-DEFZN2qS.js → m3-DN3Xgolo.js} +0 -0
- /package/src/client/dist/spa/assets/{markdown-Cbt4TlFt.js → markdown-DCCTbSQf.js} +0 -0
- /package/src/client/dist/spa/assets/{mips-C6m4XECw.js → mips-lh5qv6lw.js} +0 -0
- /package/src/client/dist/spa/assets/{msdax-un0CFb_S.js → msdax-ikHtaqdR.js} +0 -0
- /package/src/client/dist/spa/assets/{mysql-CuAPeiOV.js → mysql-yyeeFEN0.js} +0 -0
- /package/src/client/dist/spa/assets/{objective-c-DLVMdxAC.js → objective-c-BCpwk8Ct.js} +0 -0
- /package/src/client/dist/spa/assets/{pascal-BGCThuPY.js → pascal-PL6H1Gn7.js} +0 -0
- /package/src/client/dist/spa/assets/{pascaligo-DfxSVpdo.js → pascaligo-BCtUX6M4.js} +0 -0
- /package/src/client/dist/spa/assets/{perl-BOE6y94t.js → perl-DuluA5AL.js} +0 -0
- /package/src/client/dist/spa/assets/{pgsql-Dn7JkY4F.js → pgsql-B4LkSOFV.js} +0 -0
- /package/src/client/dist/spa/assets/{php-r1gD0KyT.js → php-B3Ske963.js} +0 -0
- /package/src/client/dist/spa/assets/{pla-CgXknhb0.js → pla-B0vdKSVA.js} +0 -0
- /package/src/client/dist/spa/assets/{postiats-CsIEtnRB.js → postiats-lYIY9h0z.js} +0 -0
- /package/src/client/dist/spa/assets/{powerquery-yNJCmC_6.js → powerquery-e_CNZlRH.js} +0 -0
- /package/src/client/dist/spa/assets/{powershell-CQcz1SqH.js → powershell-B7ny7eNr.js} +0 -0
- /package/src/client/dist/spa/assets/{protobuf-BmC34uvO.js → protobuf-T5b6INIm.js} +0 -0
- /package/src/client/dist/spa/assets/{pug-C20znvWM.js → pug-DB5iDfTd.js} +0 -0
- /package/src/client/dist/spa/assets/{qsharp-B7bnARMS.js → qsharp-DwB29woK.js} +0 -0
- /package/src/client/dist/spa/assets/{r-ClvcLdqC.js → r-DGAconUr.js} +0 -0
- /package/src/client/dist/spa/assets/{redis-DCyda7_S.js → redis-CBlUxt29.js} +0 -0
- /package/src/client/dist/spa/assets/{redshift-BtWDr4pb.js → redshift-DUdydw8b.js} +0 -0
- /package/src/client/dist/spa/assets/{restructuredtext-CLcnlkhl.js → restructuredtext-CpFidj3o.js} +0 -0
- /package/src/client/dist/spa/assets/{ruby-DY0SOSSZ.js → ruby-DSI1pDHV.js} +0 -0
- /package/src/client/dist/spa/assets/{rust-JQd-fJZI.js → rust-DuAjdlB2.js} +0 -0
- /package/src/client/dist/spa/assets/{sb-BV2j8yFF.js → sb-DHK18yUj.js} +0 -0
- /package/src/client/dist/spa/assets/{scala-DwbnREDs.js → scala-OUOqN_hV.js} +0 -0
- /package/src/client/dist/spa/assets/{scheme-CrtA-vei.js → scheme-1CW-bJnQ.js} +0 -0
- /package/src/client/dist/spa/assets/{scss-VxQz3zmI.js → scss-C5o7X-EM.js} +0 -0
- /package/src/client/dist/spa/assets/{shell-CP9faqFI.js → shell-CtGzMV7Q.js} +0 -0
- /package/src/client/dist/spa/assets/{solidity-9IIb0b89.js → solidity-DPkz2VqZ.js} +0 -0
- /package/src/client/dist/spa/assets/{sophia-D2LQU2AD.js → sophia-nnptfdLN.js} +0 -0
- /package/src/client/dist/spa/assets/{sparql-DONCa5dy.js → sparql-C6bexdnc.js} +0 -0
- /package/src/client/dist/spa/assets/{sql-DaAAHGEt.js → sql-CcLlvkTm.js} +0 -0
- /package/src/client/dist/spa/assets/{st-CRY2V-j3.js → st-C7Y7CLp_.js} +0 -0
- /package/src/client/dist/spa/assets/{swift-BlKbfloF.js → swift-7Jj4IMNp.js} +0 -0
- /package/src/client/dist/spa/assets/{systemverilog-B_h9Q_T_.js → systemverilog-BBh4Cn1v.js} +0 -0
- /package/src/client/dist/spa/assets/{tcl-C4wN3A6M.js → tcl-BivXXY1v.js} +0 -0
- /package/src/client/dist/spa/assets/{twig-DDdaBLC9.js → twig-CuImSpsA.js} +0 -0
- /package/src/client/dist/spa/assets/{typespec-Dc1ipt8A.js → typespec-s1tz7uxH.js} +0 -0
- /package/src/client/dist/spa/assets/{vb-C4BXIvrh.js → vb-AURJL6ia.js} +0 -0
- /package/src/client/dist/spa/assets/{wgsl-XVg3Pi-r.js → wgsl-Dq1-vM4I.js} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile as execFileCb } from 'node:child_process';
|
|
1
|
+
import { execFile as execFileCb, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const execFileAsync = promisify(execFileCb);
|
|
4
4
|
import fs from 'node:fs';
|
|
@@ -15,8 +15,10 @@ import * as wsService from '../services/websocket-service.js';
|
|
|
15
15
|
import * as workspaceService from '../services/workspace-service.js';
|
|
16
16
|
import * as worktreeService from '../services/worktree-service.js';
|
|
17
17
|
import * as gitOps from '../utils/git-ops.js';
|
|
18
|
+
/** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
|
|
18
19
|
const app = new Hono();
|
|
19
|
-
|
|
20
|
+
/** Tracks workspaces currently running a setup script to prevent concurrent executions. */
|
|
21
|
+
const setupScriptRunning = new Set();
|
|
20
22
|
app.get('/', (c) => {
|
|
21
23
|
try {
|
|
22
24
|
const workspaces = workspaceService.listWorkspaces();
|
|
@@ -34,7 +36,7 @@ app.post('/', async (c) => {
|
|
|
34
36
|
if (!body.name || !body.projectPath || !body.sourceBranch || !body.workingBranch) {
|
|
35
37
|
return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch, workingBranch' }, 400);
|
|
36
38
|
}
|
|
37
|
-
//
|
|
39
|
+
// Create workspace record
|
|
38
40
|
let workspace = workspaceService.createWorkspace({
|
|
39
41
|
name: body.name,
|
|
40
42
|
projectPath: body.projectPath,
|
|
@@ -45,7 +47,7 @@ app.post('/', async (c) => {
|
|
|
45
47
|
model: body.model,
|
|
46
48
|
});
|
|
47
49
|
let notionContent = null;
|
|
48
|
-
//
|
|
50
|
+
// Extract Notion page content if a URL was provided
|
|
49
51
|
if (body.notionUrl) {
|
|
50
52
|
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
51
53
|
try {
|
|
@@ -56,7 +58,7 @@ app.post('/', async (c) => {
|
|
|
56
58
|
console.error(`[workspaces] Failed to extract Notion page: ${message}`);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
//
|
|
61
|
+
// Create tasks from extracted Notion data
|
|
60
62
|
if (notionContent) {
|
|
61
63
|
let sortOrder = 0;
|
|
62
64
|
for (const todo of notionContent.todos) {
|
|
@@ -104,7 +106,7 @@ app.post('/', async (c) => {
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
|
-
//
|
|
109
|
+
// Create git worktree for the working branch
|
|
108
110
|
let worktreePath;
|
|
109
111
|
try {
|
|
110
112
|
worktreePath = worktreeService.createWorktree(body.projectPath, body.workingBranch, body.sourceBranch);
|
|
@@ -114,45 +116,54 @@ app.post('/', async (c) => {
|
|
|
114
116
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
115
117
|
return c.json({ error: `Failed to create worktree: ${message}` }, 500);
|
|
116
118
|
}
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
+
// Ensure Kobo-generated files are gitignored. Check both the root
|
|
120
|
+
// .gitignore and .ai/.gitignore to avoid duplicate entries.
|
|
119
121
|
try {
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
const
|
|
122
|
+
const rootGitignorePath = path.join(worktreePath, '.gitignore');
|
|
123
|
+
const aiGitignorePath = path.join(worktreePath, '.ai', '.gitignore');
|
|
124
|
+
const rootContent = fs.existsSync(rootGitignorePath) ? fs.readFileSync(rootGitignorePath, 'utf-8') : '';
|
|
125
|
+
const rootLines = rootContent.split('\n').map((l) => l.trim());
|
|
126
|
+
const aiContent = fs.existsSync(aiGitignorePath) ? fs.readFileSync(aiGitignorePath, 'utf-8') : '';
|
|
127
|
+
const aiLines = aiContent.split('\n').map((l) => l.trim());
|
|
128
|
+
// Each entry: [pattern for root .gitignore, equivalent pattern in .ai/.gitignore]
|
|
129
|
+
const entries = [
|
|
130
|
+
['.ai/.git-conventions.md', '.git-conventions.md'],
|
|
131
|
+
['.ai/thoughts/', 'thoughts/'],
|
|
132
|
+
['.ai/images/', 'images/'],
|
|
133
|
+
['.ai/.setup-script.tmp', '.setup-script.tmp'],
|
|
134
|
+
['.mcp.json', ''],
|
|
135
|
+
];
|
|
123
136
|
const toAdd = [];
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!lines.includes('.ai/.setup-script.tmp'))
|
|
131
|
-
toAdd.push('.ai/.setup-script.tmp');
|
|
137
|
+
for (const [rootPattern, aiPattern] of entries) {
|
|
138
|
+
const inRoot = rootLines.includes(rootPattern);
|
|
139
|
+
const inAi = aiPattern && aiLines.includes(aiPattern);
|
|
140
|
+
if (!inRoot && !inAi)
|
|
141
|
+
toAdd.push(rootPattern);
|
|
142
|
+
}
|
|
132
143
|
if (toAdd.length > 0) {
|
|
133
|
-
const separator =
|
|
134
|
-
fs.appendFileSync(
|
|
144
|
+
const separator = rootContent.length > 0 && !rootContent.endsWith('\n') ? '\n' : '';
|
|
145
|
+
fs.appendFileSync(rootGitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
|
|
135
146
|
}
|
|
136
147
|
}
|
|
137
148
|
catch (err) {
|
|
138
149
|
console.error('[workspaces] Failed to update .gitignore:', err);
|
|
139
150
|
}
|
|
140
|
-
//
|
|
151
|
+
// Write git conventions to the worktree if configured
|
|
141
152
|
const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
|
|
142
153
|
if (effectiveSettings.gitConventions) {
|
|
143
154
|
try {
|
|
144
155
|
const aiDir = path.join(worktreePath, '.ai');
|
|
145
156
|
fs.mkdirSync(aiDir, { recursive: true });
|
|
146
|
-
const conventionsPath = path.join(aiDir, 'git-conventions.md');
|
|
157
|
+
const conventionsPath = path.join(aiDir, '.git-conventions.md');
|
|
147
158
|
fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
|
|
148
159
|
}
|
|
149
160
|
catch (err) {
|
|
150
|
-
console.error('[workspaces] Failed to write git-conventions.md:', err);
|
|
161
|
+
console.error('[workspaces] Failed to write .git-conventions.md:', err);
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
|
-
//
|
|
164
|
+
// Run setup script if configured and not skipped
|
|
154
165
|
let setupScriptFailed = false;
|
|
155
|
-
if (effectiveSettings.setupScript) {
|
|
166
|
+
if (effectiveSettings.setupScript && !body.skipSetupScript) {
|
|
156
167
|
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
157
168
|
wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
|
|
158
169
|
try {
|
|
@@ -174,17 +185,20 @@ app.post('/', async (c) => {
|
|
|
174
185
|
setupScriptFailed = true;
|
|
175
186
|
}
|
|
176
187
|
}
|
|
177
|
-
//
|
|
188
|
+
// Save Notion content as markdown in worktree
|
|
178
189
|
let notionFilePath = null;
|
|
179
190
|
if (notionContent && body.notionUrl) {
|
|
180
191
|
try {
|
|
181
192
|
const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
|
|
182
193
|
fs.mkdirSync(thoughtsDir, { recursive: true });
|
|
183
|
-
// Derive filename from
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
// Derive filename from Notion ticket ID, or fallback to branch/name pattern
|
|
195
|
+
const notionTicketId = notionContent.ticketId;
|
|
196
|
+
const fallbackMatch = `${workspace.name} ${body.workingBranch}`.match(/TK-\d+/i);
|
|
197
|
+
const filename = notionTicketId
|
|
198
|
+
? `${notionTicketId.toUpperCase()}.md`
|
|
199
|
+
: fallbackMatch
|
|
200
|
+
? `${fallbackMatch[0].toUpperCase()}.md`
|
|
201
|
+
: `PAGE-${notionService.parseNotionUrl(body.notionUrl).replace(/-/g, '')}.md`;
|
|
188
202
|
notionFilePath = path.join(thoughtsDir, filename);
|
|
189
203
|
const today = new Date().toISOString().split('T')[0];
|
|
190
204
|
let md = `# ${workspace.name}\n\n`;
|
|
@@ -215,17 +229,22 @@ app.post('/', async (c) => {
|
|
|
215
229
|
}
|
|
216
230
|
// Skip agent launch if setup script failed — workspace stays in 'error' status
|
|
217
231
|
if (!setupScriptFailed) {
|
|
218
|
-
//
|
|
232
|
+
// Transition to brainstorming and build the initial agent prompt
|
|
219
233
|
workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
|
|
220
|
-
//
|
|
234
|
+
// Build prompt with tasks and acceptance criteria
|
|
221
235
|
const allTasks = workspaceService.listTasks(workspace.id);
|
|
222
236
|
const todos = allTasks.filter((t) => !t.isAcceptanceCriterion);
|
|
223
237
|
const criteria = allTasks.filter((t) => t.isAcceptanceCriterion);
|
|
224
238
|
let brainstormPrompt = `You are working on: ${workspace.name}\n`;
|
|
239
|
+
// Include ticket ID if found so the agent uses the correct reference
|
|
240
|
+
const ticketId = notionContent?.ticketId || `${workspace.name} ${body.workingBranch}`.match(/TK-\d+/i)?.[0];
|
|
241
|
+
if (ticketId) {
|
|
242
|
+
brainstormPrompt += `Ticket: ${ticketId.toUpperCase()}\n`;
|
|
243
|
+
}
|
|
225
244
|
if (notionContent?.goal) {
|
|
226
245
|
brainstormPrompt += `\nGoal: ${notionContent.goal}\n`;
|
|
227
246
|
}
|
|
228
|
-
brainstormPrompt += `\nBranch: ${body.workingBranch}\n`;
|
|
247
|
+
brainstormPrompt += `\nBranch: ${body.workingBranch}\nSource branch: ${body.sourceBranch}\nIMPORTANT: When creating a pull request, always use --base ${body.sourceBranch} to target the correct source branch.\n`;
|
|
229
248
|
if (notionFilePath) {
|
|
230
249
|
brainstormPrompt += `\nNotion ticket: ${body.notionUrl}`;
|
|
231
250
|
brainstormPrompt += `\nLocal copy: ${notionFilePath}\n`;
|
|
@@ -236,14 +255,17 @@ app.post('/', async (c) => {
|
|
|
236
255
|
if (criteria.length > 0) {
|
|
237
256
|
brainstormPrompt += `\nAcceptance criteria:\n${criteria.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
|
|
238
257
|
}
|
|
258
|
+
brainstormPrompt += `\nYou have access to MCP tools via the 'kobo-tasks' server:\n`;
|
|
239
259
|
if (criteria.length > 0 || todos.length > 0) {
|
|
240
|
-
brainstormPrompt += `\nYou have access to MCP tools via the 'kobo-tasks' server:\n`;
|
|
241
260
|
brainstormPrompt += `- list_tasks() — list all tasks and criteria with their IDs and current status\n`;
|
|
242
261
|
brainstormPrompt += `- mark_task_done(task_id) — mark a task or criterion as done\n`;
|
|
243
|
-
brainstormPrompt += `\nAs you
|
|
262
|
+
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`;
|
|
263
|
+
}
|
|
264
|
+
if (body.notionUrl) {
|
|
265
|
+
brainstormPrompt += `- get_notion_ticket() — retrieve the Notion ticket info (URL, ticket ID, extracted content)\n`;
|
|
244
266
|
}
|
|
245
267
|
if (effectiveSettings.gitConventions) {
|
|
246
|
-
brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai
|
|
268
|
+
brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/.git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
|
|
247
269
|
}
|
|
248
270
|
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.`;
|
|
249
271
|
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.`;
|
|
@@ -576,6 +598,82 @@ app.patch('/:id', async (c) => {
|
|
|
576
598
|
return c.json({ error: message }, 500);
|
|
577
599
|
}
|
|
578
600
|
});
|
|
601
|
+
/** Open the workspace worktree in the user's configured editor. */
|
|
602
|
+
app.post('/:id/open-editor', (c) => {
|
|
603
|
+
try {
|
|
604
|
+
const id = c.req.param('id');
|
|
605
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
606
|
+
if (!workspace)
|
|
607
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
608
|
+
const globalSettings = settingsService.getGlobalSettings();
|
|
609
|
+
if (!globalSettings.editorCommand) {
|
|
610
|
+
return c.json({ error: 'No editor command configured' }, 400);
|
|
611
|
+
}
|
|
612
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
613
|
+
if (!fs.existsSync(worktreePath)) {
|
|
614
|
+
return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
|
|
615
|
+
}
|
|
616
|
+
const child = spawn(globalSettings.editorCommand, [worktreePath], {
|
|
617
|
+
detached: true,
|
|
618
|
+
stdio: 'ignore',
|
|
619
|
+
});
|
|
620
|
+
child.unref();
|
|
621
|
+
return c.json({ success: true });
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
625
|
+
return c.json({ error: message }, 500);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
/** Re-run the project setup script in the workspace worktree. */
|
|
629
|
+
app.post('/:id/run-setup-script', async (c) => {
|
|
630
|
+
try {
|
|
631
|
+
const id = c.req.param('id');
|
|
632
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
633
|
+
if (!workspace)
|
|
634
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
635
|
+
if (setupScriptRunning.has(id)) {
|
|
636
|
+
return c.json({ error: 'Setup script is already running for this workspace' }, 409);
|
|
637
|
+
}
|
|
638
|
+
// Stop the running agent before re-running the setup script
|
|
639
|
+
try {
|
|
640
|
+
if (agentManager.getAgentStatus(id)) {
|
|
641
|
+
agentManager.stopAgent(id);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
/* best-effort — agent may already be stopped */
|
|
646
|
+
}
|
|
647
|
+
const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
648
|
+
if (!effectiveSettings.setupScript) {
|
|
649
|
+
return c.json({ error: 'No setup script configured' }, 400);
|
|
650
|
+
}
|
|
651
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
652
|
+
if (!fs.existsSync(worktreePath)) {
|
|
653
|
+
return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
|
|
654
|
+
}
|
|
655
|
+
setupScriptRunning.add(id);
|
|
656
|
+
try {
|
|
657
|
+
const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
|
|
658
|
+
workspaceName: workspace.name,
|
|
659
|
+
branchName: workspace.workingBranch,
|
|
660
|
+
sourceBranch: workspace.sourceBranch,
|
|
661
|
+
projectPath: workspace.projectPath,
|
|
662
|
+
});
|
|
663
|
+
if (result.exitCode !== 0) {
|
|
664
|
+
return c.json({ error: `Setup script failed with exit code ${result.exitCode}` }, 500);
|
|
665
|
+
}
|
|
666
|
+
return c.json({ success: true });
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
setupScriptRunning.delete(id);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
674
|
+
return c.json({ error: message }, 500);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
579
677
|
// POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
|
|
580
678
|
app.post('/:id/archive', (c) => {
|
|
581
679
|
try {
|
|
@@ -641,14 +739,14 @@ app.delete('/:id', async (c) => {
|
|
|
641
739
|
const body = await c.req
|
|
642
740
|
.json()
|
|
643
741
|
.catch(() => ({}));
|
|
644
|
-
//
|
|
742
|
+
// Stop agent if running (best-effort)
|
|
645
743
|
try {
|
|
646
744
|
agentManager.stopAgent(id);
|
|
647
745
|
}
|
|
648
746
|
catch {
|
|
649
747
|
// Agent may not be running — ignore
|
|
650
748
|
}
|
|
651
|
-
//
|
|
749
|
+
// Remove worktree
|
|
652
750
|
const worktreesDir = `${workspace.projectPath}/.worktrees`;
|
|
653
751
|
const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
|
|
654
752
|
try {
|
|
@@ -658,7 +756,7 @@ app.delete('/:id', async (c) => {
|
|
|
658
756
|
const message = err instanceof Error ? err.message : String(err);
|
|
659
757
|
console.error(`[workspaces] Failed to remove worktree: ${message}`);
|
|
660
758
|
}
|
|
661
|
-
//
|
|
759
|
+
// Delete local branch if requested
|
|
662
760
|
if (body.deleteLocalBranch) {
|
|
663
761
|
try {
|
|
664
762
|
gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
|
|
@@ -668,7 +766,7 @@ app.delete('/:id', async (c) => {
|
|
|
668
766
|
console.error(`[workspaces] Failed to delete local branch: ${message}`);
|
|
669
767
|
}
|
|
670
768
|
}
|
|
671
|
-
//
|
|
769
|
+
// Delete remote branch if requested
|
|
672
770
|
if (body.deleteRemoteBranch) {
|
|
673
771
|
try {
|
|
674
772
|
gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
|
|
@@ -678,7 +776,7 @@ app.delete('/:id', async (c) => {
|
|
|
678
776
|
console.error(`[workspaces] Failed to delete remote branch: ${message}`);
|
|
679
777
|
}
|
|
680
778
|
}
|
|
681
|
-
//
|
|
779
|
+
// Delete workspace from DB (cascades to tasks, sessions, events)
|
|
682
780
|
workspaceService.deleteWorkspace(id);
|
|
683
781
|
return new Response(null, { status: 204 });
|
|
684
782
|
}
|
|
@@ -823,7 +921,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
823
921
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
824
922
|
}
|
|
825
923
|
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
826
|
-
//
|
|
924
|
+
// Verify branch exists on remote
|
|
827
925
|
let lsRemoteOut = '';
|
|
828
926
|
try {
|
|
829
927
|
const { stdout } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
|
|
@@ -837,7 +935,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
837
935
|
if (!lsRemoteOut.trim()) {
|
|
838
936
|
return c.json({ error: 'Branch is not on remote', code: 'branch_not_pushed' }, 409);
|
|
839
937
|
}
|
|
840
|
-
//
|
|
938
|
+
// Ensure all local commits are pushed
|
|
841
939
|
try {
|
|
842
940
|
const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
|
|
843
941
|
const countStr = stdout.trim();
|
|
@@ -855,7 +953,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
855
953
|
}
|
|
856
954
|
return c.json({ error: `Failed to check branch state: ${message}` }, 500);
|
|
857
955
|
}
|
|
858
|
-
//
|
|
956
|
+
// Create PR via GitHub CLI
|
|
859
957
|
let ghOutput;
|
|
860
958
|
try {
|
|
861
959
|
const placeholderBody = 'Automated PR — description will be updated by the agent.';
|
|
@@ -878,7 +976,7 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
878
976
|
const stderr = err.stderr?.toString() ?? '';
|
|
879
977
|
return c.json({ error: `gh pr create failed: ${message} ${stderr}`.trim() }, 500);
|
|
880
978
|
}
|
|
881
|
-
//
|
|
979
|
+
// Parse PR URL and number from gh output
|
|
882
980
|
const urlMatch = ghOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
|
|
883
981
|
if (!urlMatch) {
|
|
884
982
|
return c.json({ error: 'Could not parse PR URL from gh output' }, 500);
|
|
@@ -886,12 +984,12 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
886
984
|
const prUrl = urlMatch[0];
|
|
887
985
|
const prNumber = parseInt(urlMatch[1], 10);
|
|
888
986
|
// ── From here on, PR exists. No more 5xx responses. ──
|
|
889
|
-
//
|
|
987
|
+
// Resolve the PR prompt template; skip message steps if empty
|
|
890
988
|
const effective = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
891
989
|
if (!effective.prPromptTemplate) {
|
|
892
990
|
return c.json({ ok: true, prNumber, prUrl, messageSent: false });
|
|
893
991
|
}
|
|
894
|
-
//
|
|
992
|
+
// Build context and render the PR prompt template
|
|
895
993
|
const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
896
994
|
const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
897
995
|
const tasks = workspaceService.listTasks(workspace.id);
|
|
@@ -903,11 +1001,11 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
903
1001
|
diffStats,
|
|
904
1002
|
tasks,
|
|
905
1003
|
});
|
|
906
|
-
//
|
|
1004
|
+
// Emit user:message into the chat feed
|
|
907
1005
|
const session = workspaceService.getLatestSession(workspace.id);
|
|
908
1006
|
const sessionId = session?.claudeSessionId ?? undefined;
|
|
909
1007
|
wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
910
|
-
//
|
|
1008
|
+
// Send to the running agent, or resume the agent with the PR prompt
|
|
911
1009
|
let messageSent = false;
|
|
912
1010
|
try {
|
|
913
1011
|
agentManager.sendMessage(workspace.id, rendered);
|
|
@@ -933,6 +1031,23 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
933
1031
|
return c.json({ error: message }, 500);
|
|
934
1032
|
}
|
|
935
1033
|
});
|
|
1034
|
+
/** POST /api/workspaces/:id/mark-read — mark workspace as read (clear unread indicator). */
|
|
1035
|
+
app.post('/:id/mark-read', (c) => {
|
|
1036
|
+
try {
|
|
1037
|
+
const id = c.req.param('id');
|
|
1038
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1039
|
+
if (!workspace) {
|
|
1040
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1041
|
+
}
|
|
1042
|
+
workspaceService.markWorkspaceRead(id);
|
|
1043
|
+
wsService.emitEphemeral(id, 'workspace:unread', { hasUnread: false });
|
|
1044
|
+
return c.json({ success: true });
|
|
1045
|
+
}
|
|
1046
|
+
catch (err) {
|
|
1047
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1048
|
+
return c.json({ error: message }, 500);
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
936
1051
|
// POST /api/workspaces/:id/stop — stop agent
|
|
937
1052
|
app.post('/:id/stop', (c) => {
|
|
938
1053
|
try {
|
|
@@ -7,8 +7,8 @@ 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
9
|
import { getEffectiveSettings } from './settings-service.js';
|
|
10
|
-
import { emit } from './websocket-service.js';
|
|
11
|
-
import { getWorkspace as getWs, listTasks, updateWorkspaceStatus } from './workspace-service.js';
|
|
10
|
+
import { emit, emitEphemeral } from './websocket-service.js';
|
|
11
|
+
import { getWorkspace as getWs, listTasks, markWorkspaceUnread, updateWorkspaceStatus } from './workspace-service.js';
|
|
12
12
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
13
13
|
/** Actual bound port of the running backend — set at startup via setBackendPort() */
|
|
14
14
|
let backendPort = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
|
@@ -82,6 +82,13 @@ function runWatchdog() {
|
|
|
82
82
|
catch {
|
|
83
83
|
// Transition may not be valid — ignore
|
|
84
84
|
}
|
|
85
|
+
try {
|
|
86
|
+
markWorkspaceUnread(workspaceId);
|
|
87
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// best-effort
|
|
91
|
+
}
|
|
85
92
|
emit(workspaceId, 'agent:status', { status: 'error', message: 'Agent process died unexpectedly' }, agent.claudeSessionId);
|
|
86
93
|
}
|
|
87
94
|
}
|
|
@@ -100,6 +107,7 @@ export function stopWatchdog() {
|
|
|
100
107
|
}
|
|
101
108
|
}
|
|
102
109
|
// ── Start agent ────────────────────────────────────────────────────────────────
|
|
110
|
+
/** Spawn a Claude Code CLI process for a workspace and wire up stdout/stderr/exit handling. */
|
|
103
111
|
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept') {
|
|
104
112
|
// Check if agent already running for this workspace
|
|
105
113
|
if (agents.has(workspaceId)) {
|
|
@@ -301,7 +309,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
301
309
|
});
|
|
302
310
|
// ── stderr — detect quota / rate limit errors ──
|
|
303
311
|
proc.stderr?.on('data', (data) => {
|
|
304
|
-
// I1: Don't process quota errors if the agent is already stopping or gone
|
|
305
312
|
const currentAgent = agents.get(workspaceId);
|
|
306
313
|
if (!currentAgent || currentAgent.status === 'stopping')
|
|
307
314
|
return;
|
|
@@ -322,7 +329,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
322
329
|
catch {
|
|
323
330
|
// File may not exist (spawn failed) — ignore
|
|
324
331
|
}
|
|
325
|
-
// I3: Close readline interface to release the stream reference
|
|
326
332
|
agent.rl.close();
|
|
327
333
|
unregisterProcess(workspaceId);
|
|
328
334
|
// Only remove from the map if this exact agent instance is still current.
|
|
@@ -334,7 +340,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
334
340
|
}
|
|
335
341
|
// Clean up retry state and inactivity timer
|
|
336
342
|
retryCounts.delete(workspaceId);
|
|
337
|
-
//
|
|
343
|
+
// Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
|
|
338
344
|
const pendingKillTimer = killTimers.get(workspaceId);
|
|
339
345
|
if (pendingKillTimer) {
|
|
340
346
|
clearTimeout(pendingKillTimer);
|
|
@@ -350,7 +356,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
350
356
|
emit(workspaceId, 'agent:status', { status: 'stopped' }, agent.claudeSessionId);
|
|
351
357
|
return;
|
|
352
358
|
}
|
|
353
|
-
//
|
|
359
|
+
// Also clear backoff timers on non-stopping exit
|
|
354
360
|
const pendingBackoff = backoffTimers.get(workspaceId);
|
|
355
361
|
if (pendingBackoff) {
|
|
356
362
|
clearTimeout(pendingBackoff);
|
|
@@ -363,6 +369,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
363
369
|
catch (err) {
|
|
364
370
|
console.error('[agent] Failed to update workspace status on exit:', err);
|
|
365
371
|
}
|
|
372
|
+
try {
|
|
373
|
+
markWorkspaceUnread(workspaceId);
|
|
374
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// best-effort
|
|
378
|
+
}
|
|
366
379
|
emit(workspaceId, 'agent:status', { status: 'error', exitCode: code }, agent.claudeSessionId);
|
|
367
380
|
}
|
|
368
381
|
else {
|
|
@@ -372,6 +385,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
372
385
|
catch (err) {
|
|
373
386
|
console.error('[agent] Failed to update workspace status on exit:', err);
|
|
374
387
|
}
|
|
388
|
+
try {
|
|
389
|
+
markWorkspaceUnread(workspaceId);
|
|
390
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// best-effort
|
|
394
|
+
}
|
|
375
395
|
emit(workspaceId, 'agent:status', { status: 'completed' }, agent.claudeSessionId);
|
|
376
396
|
}
|
|
377
397
|
});
|
|
@@ -382,6 +402,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
382
402
|
return agent;
|
|
383
403
|
}
|
|
384
404
|
// ── Stop agent ─────────────────────────────────────────────────────────────────
|
|
405
|
+
/** Gracefully stop an agent (SIGTERM, then SIGKILL after 5s). */
|
|
385
406
|
export function stopAgent(workspaceId) {
|
|
386
407
|
const agent = agents.get(workspaceId);
|
|
387
408
|
if (!agent) {
|
|
@@ -398,7 +419,6 @@ export function stopAgent(workspaceId) {
|
|
|
398
419
|
clearTimeout(timer);
|
|
399
420
|
backoffTimers.delete(workspaceId);
|
|
400
421
|
}
|
|
401
|
-
// I3: Close readline interface now that we're stopping
|
|
402
422
|
try {
|
|
403
423
|
agent.rl.close();
|
|
404
424
|
}
|
|
@@ -414,7 +434,7 @@ export function stopAgent(workspaceId) {
|
|
|
414
434
|
}
|
|
415
435
|
// After 5s timeout, send SIGKILL if still running
|
|
416
436
|
const killTimer = setTimeout(() => {
|
|
417
|
-
//
|
|
437
|
+
// If a new agent has been started for this workspace in the meantime,
|
|
418
438
|
// don't kill the old process — it's handled by the new lifecycle.
|
|
419
439
|
const currentAgent = agents.get(workspaceId);
|
|
420
440
|
if (currentAgent && currentAgent !== agent) {
|
|
@@ -436,6 +456,7 @@ export function stopAgent(workspaceId) {
|
|
|
436
456
|
killTimers.set(workspaceId, killTimer);
|
|
437
457
|
}
|
|
438
458
|
// ── Send message to agent stdin ────────────────────────────────────────────────
|
|
459
|
+
/** Write a user message to the running agent's stdin. */
|
|
439
460
|
export function sendMessage(workspaceId, content) {
|
|
440
461
|
const agent = agents.get(workspaceId);
|
|
441
462
|
if (!agent) {
|
|
@@ -447,15 +468,20 @@ export function sendMessage(workspaceId, content) {
|
|
|
447
468
|
agent.process.stdin.write(`${content}\n`);
|
|
448
469
|
}
|
|
449
470
|
// ── Status queries ─────────────────────────────────────────────────────────────
|
|
471
|
+
/** Get the in-memory status of the agent for a workspace, or null if not running. */
|
|
450
472
|
export function getAgentStatus(workspaceId) {
|
|
451
473
|
const agent = agents.get(workspaceId);
|
|
452
474
|
return agent?.status ?? null;
|
|
453
475
|
}
|
|
476
|
+
/** Return the number of currently running agents. */
|
|
454
477
|
export function getRunningCount() {
|
|
455
478
|
return agents.size;
|
|
456
479
|
}
|
|
480
|
+
/** Kobo built-in slash commands injected into the skill list (without leading /). */
|
|
481
|
+
const KOBO_COMMANDS = ['kobo-check-progress'];
|
|
482
|
+
/** Return the cached list of slash commands discovered from the last agent init, plus Kobo built-in commands. */
|
|
457
483
|
export function getAvailableSkills() {
|
|
458
|
-
return availableSkills;
|
|
484
|
+
return [...KOBO_COMMANDS, ...availableSkills];
|
|
459
485
|
}
|
|
460
486
|
// ── Quota handling ─────────────────────────────────────────────────────────────
|
|
461
487
|
function handleQuota(workspaceId, claudeSessionId) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// src/server/services/image-service.ts
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { nanoid } from 'nanoid';
|
|
@@ -30,6 +29,7 @@ function readIndex(imagesDir) {
|
|
|
30
29
|
function writeIndex(imagesDir, entries) {
|
|
31
30
|
fs.writeFileSync(path.join(imagesDir, INDEX_FILE), JSON.stringify(entries, null, 2));
|
|
32
31
|
}
|
|
32
|
+
/** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
|
|
33
33
|
export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
34
34
|
const ext = path.extname(originalName).toLowerCase().replace('.', '');
|
|
35
35
|
if (!ext) {
|
|
@@ -53,6 +53,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
53
53
|
});
|
|
54
54
|
return { uid, relativePath: `${IMAGES_DIR}/${filename}` };
|
|
55
55
|
}
|
|
56
|
+
/** Delete an image by UID from disk and the index. */
|
|
56
57
|
export async function deleteImage(worktreePath, uid) {
|
|
57
58
|
const imagesDir = path.join(worktreePath, IMAGES_DIR);
|
|
58
59
|
await withLock(worktreePath, () => {
|