@loicngr/kobo 0.1.1
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 +227 -0
- package/LICENSE +674 -0
- package/README.md +199 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +27 -0
- package/dist/mcp-server/kobo-tasks-server.js +116 -0
- package/dist/server/db/index.js +22 -0
- package/dist/server/db/migrations.js +20 -0
- package/dist/server/db/schema.js +49 -0
- package/dist/server/index.js +178 -0
- package/dist/server/routes/dev-server.js +74 -0
- package/dist/server/routes/git.js +20 -0
- package/dist/server/routes/notion.js +24 -0
- package/dist/server/routes/settings.js +92 -0
- package/dist/server/routes/workspaces.js +730 -0
- package/dist/server/services/agent-manager.js +435 -0
- package/dist/server/services/dev-server-service.js +298 -0
- package/dist/server/services/notion-service.js +369 -0
- package/dist/server/services/pr-template-service.js +38 -0
- package/dist/server/services/settings-service.js +205 -0
- package/dist/server/services/websocket-service.js +212 -0
- package/dist/server/services/workspace-service.js +208 -0
- package/dist/server/services/worktree-service.js +117 -0
- package/dist/server/utils/git-ops.js +117 -0
- package/dist/server/utils/paths.js +95 -0
- package/dist/server/utils/process-tracker.js +46 -0
- package/package.json +84 -0
- package/src/client/dist/spa/assets/ActivityFeed-BveJRagX.js +60 -0
- package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-wbOkBwYU.js +2 -0
- 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-6hzaLlYO.js +1 -0
- package/src/client/dist/spa/assets/MainLayout-D0OU6djX.css +1 -0
- package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +1 -0
- package/src/client/dist/spa/assets/QDialog-B5H6ayTp.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-DJgnAZg_.js +1 -0
- package/src/client/dist/spa/assets/QPage-CLk9i9z8.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-DcaNq8uL.js +1 -0
- package/src/client/dist/spa/assets/QTabPanels-DlG5TZhP.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-KEqbLZUA.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BFuHLjou.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-D0Hm21LY.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +1 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ-D-x-0Q06.woff2 +0 -0
- package/src/client/dist/spa/assets/index-BThMCiY7.css +1 -0
- package/src/client/dist/spa/assets/index-CMvo3OTb.js +5 -0
- package/src/client/dist/spa/assets/nodes-DeIen-kp.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +1 -0
- package/src/client/dist/spa/index.html +4 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +54 -0
- package/src/mcp-server/kobo-tasks-server.ts +128 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs, { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import { nanoid } from 'nanoid';
|
|
6
|
+
import { getDb } from '../db/index.js';
|
|
7
|
+
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath, getSkillsPath, } from '../utils/paths.js';
|
|
8
|
+
import { registerProcess, unregisterProcess } from '../utils/process-tracker.js';
|
|
9
|
+
import { emit } from './websocket-service.js';
|
|
10
|
+
import { getWorkspace as getWs, listTasks, updateWorkspaceStatus } from './workspace-service.js';
|
|
11
|
+
// ── State ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
/** workspaceId -> agent instance */
|
|
13
|
+
const agents = new Map();
|
|
14
|
+
/** workspaceId -> last Claude session ID (for --resume) */
|
|
15
|
+
const sessionIds = new Map();
|
|
16
|
+
/** Cached list of available slash commands — persisted to <KOBO_HOME>/skills.json */
|
|
17
|
+
let availableSkills = (() => {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(readFileSync(getSkillsPath(), 'utf-8'));
|
|
20
|
+
return Array.isArray(data) ? data : [];
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
26
|
+
/** workspaceId -> retry count (for quota backoff) */
|
|
27
|
+
const retryCounts = new Map();
|
|
28
|
+
/** workspaceId -> backoff timer */
|
|
29
|
+
const backoffTimers = new Map();
|
|
30
|
+
/** workspaceId -> pending SIGKILL timer */
|
|
31
|
+
const killTimers = new Map();
|
|
32
|
+
// ── Start agent ────────────────────────────────────────────────────────────────
|
|
33
|
+
export function startAgent(workspaceId, workingDir, prompt, model, resume = false) {
|
|
34
|
+
// Check if agent already running for this workspace
|
|
35
|
+
if (agents.has(workspaceId)) {
|
|
36
|
+
throw new Error(`Agent already running for workspace '${workspaceId}'`);
|
|
37
|
+
}
|
|
38
|
+
const db = getDb();
|
|
39
|
+
let agentSessionId;
|
|
40
|
+
// Build CLI args
|
|
41
|
+
const args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
|
|
42
|
+
if (model && model !== 'auto') {
|
|
43
|
+
args.push('--model', model);
|
|
44
|
+
}
|
|
45
|
+
if (resume) {
|
|
46
|
+
const lastSession = db
|
|
47
|
+
.prepare('SELECT id, claude_session_id FROM agent_sessions WHERE workspace_id = ? AND claude_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1')
|
|
48
|
+
.get(workspaceId);
|
|
49
|
+
const claudeSessionId = sessionIds.get(workspaceId) ?? lastSession?.claude_session_id;
|
|
50
|
+
if (claudeSessionId) {
|
|
51
|
+
args.push('--resume', claudeSessionId, '-p', prompt);
|
|
52
|
+
// Always reuse existing session — find by claude_session_id if lastSession didn't match
|
|
53
|
+
const existingId = lastSession?.id ??
|
|
54
|
+
db
|
|
55
|
+
.prepare('SELECT id FROM agent_sessions WHERE claude_session_id = ? ORDER BY started_at DESC LIMIT 1')
|
|
56
|
+
.get(claudeSessionId)?.id;
|
|
57
|
+
agentSessionId = existingId ?? nanoid();
|
|
58
|
+
if (existingId) {
|
|
59
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = NULL WHERE id = ?').run('running', agentSessionId);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, claude_session_id, started_at) VALUES (?, ?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', claudeSessionId, new Date().toISOString());
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
args.push('-p', prompt);
|
|
67
|
+
agentSessionId = nanoid();
|
|
68
|
+
db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, started_at) VALUES (?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', new Date().toISOString());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
args.push('-p', prompt);
|
|
73
|
+
agentSessionId = nanoid();
|
|
74
|
+
db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, started_at) VALUES (?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', new Date().toISOString());
|
|
75
|
+
}
|
|
76
|
+
// Write .mcp.json to workingDir so claude picks up the kobo-tasks MCP server
|
|
77
|
+
const mcpConfigPath = path.join(workingDir, '.mcp.json');
|
|
78
|
+
try {
|
|
79
|
+
const mcpServerCompiled = getCompiledMcpServerPath();
|
|
80
|
+
const mcpServerSource = getMcpServerSourcePath();
|
|
81
|
+
const mcpConfig = {
|
|
82
|
+
mcpServers: {
|
|
83
|
+
'kobo-tasks': {
|
|
84
|
+
command: mcpServerCompiled ? 'node' : 'npx',
|
|
85
|
+
args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
|
|
86
|
+
env: {
|
|
87
|
+
KOBO_WORKSPACE_ID: workspaceId,
|
|
88
|
+
KOBO_DB_PATH: getDbPath(),
|
|
89
|
+
KOBO_BACKEND_URL: `http://localhost:${process.env.PORT ?? '3000'}`,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2));
|
|
95
|
+
args.push('--mcp-config', mcpConfigPath);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error('[agent-manager] Failed to write .mcp.json, continuing without kobo-tasks MCP:', err instanceof Error ? err.message : err);
|
|
99
|
+
}
|
|
100
|
+
// Spawn Claude Code process
|
|
101
|
+
const proc = spawn('claude', args, {
|
|
102
|
+
cwd: workingDir,
|
|
103
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
104
|
+
});
|
|
105
|
+
// Create readline interface for NDJSON parsing from stdout
|
|
106
|
+
const rl = readline.createInterface({
|
|
107
|
+
input: proc.stdout,
|
|
108
|
+
crlfDelay: Infinity,
|
|
109
|
+
});
|
|
110
|
+
// Update PID in DB session
|
|
111
|
+
db.prepare('UPDATE agent_sessions SET pid = ? WHERE id = ?').run(proc.pid ?? null, agentSessionId);
|
|
112
|
+
// Register with process tracker
|
|
113
|
+
registerProcess(workspaceId, proc);
|
|
114
|
+
const agent = {
|
|
115
|
+
workspaceId,
|
|
116
|
+
process: proc,
|
|
117
|
+
rl,
|
|
118
|
+
status: 'running',
|
|
119
|
+
agentSessionId,
|
|
120
|
+
};
|
|
121
|
+
// ── stdout line-by-line (NDJSON) ──
|
|
122
|
+
rl.on('line', (line) => {
|
|
123
|
+
if (!line.trim())
|
|
124
|
+
return;
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(line);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Parsing failed — emit raw line
|
|
131
|
+
emit(workspaceId, 'agent:output', { type: 'raw', content: line }, agent.claudeSessionId);
|
|
132
|
+
// Check for BRAINSTORM_COMPLETE marker in raw lines
|
|
133
|
+
if (line.includes('[BRAINSTORM_COMPLETE]')) {
|
|
134
|
+
try {
|
|
135
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
136
|
+
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.claudeSessionId);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
console.error('[agent] Failed to transition to executing:', err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const p = parsed;
|
|
145
|
+
const msgType = p.type;
|
|
146
|
+
// Capture available skills from init message
|
|
147
|
+
if (msgType === 'system' &&
|
|
148
|
+
p.subtype === 'init' &&
|
|
149
|
+
Array.isArray(p.slash_commands) &&
|
|
150
|
+
p.slash_commands.length > 0) {
|
|
151
|
+
availableSkills = p.slash_commands;
|
|
152
|
+
try {
|
|
153
|
+
ensureKoboHome();
|
|
154
|
+
writeFileSync(getSkillsPath(), JSON.stringify(availableSkills));
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.error('[agent] Failed to persist skills:', err);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Capture session_id for --resume support
|
|
161
|
+
if (typeof p.session_id === 'string' && p.session_id) {
|
|
162
|
+
sessionIds.set(workspaceId, p.session_id);
|
|
163
|
+
if (!agent.claudeSessionId) {
|
|
164
|
+
agent.claudeSessionId = p.session_id;
|
|
165
|
+
const db = getDb();
|
|
166
|
+
db.prepare('UPDATE agent_sessions SET claude_session_id = ? WHERE id = ?').run(agent.claudeSessionId, agent.agentSessionId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// After compact, reinject criteria so the agent doesn't lose track
|
|
170
|
+
if (msgType === 'system' && (p.subtype === 'compact' || p.subtype === 'compact_boundary')) {
|
|
171
|
+
try {
|
|
172
|
+
const ws = getWs(workspaceId);
|
|
173
|
+
const tasks = listTasks(workspaceId);
|
|
174
|
+
const criteria = tasks.filter((t) => t.isAcceptanceCriterion);
|
|
175
|
+
const todos = tasks.filter((t) => !t.isAcceptanceCriterion);
|
|
176
|
+
if (criteria.length > 0 || todos.length > 0) {
|
|
177
|
+
let reminder = `\n--- Context reminder after compaction ---\n`;
|
|
178
|
+
reminder += `Task: ${ws?.name ?? workspaceId}\n`;
|
|
179
|
+
if (todos.length > 0) {
|
|
180
|
+
reminder += `\nTasks:\n${todos.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
|
|
181
|
+
}
|
|
182
|
+
if (criteria.length > 0) {
|
|
183
|
+
reminder += `\nAcceptance criteria:\n${criteria.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
|
|
184
|
+
reminder += `\nWhen you complete a criterion, tell me which one so I can mark it as done.\n`;
|
|
185
|
+
}
|
|
186
|
+
reminder += `--- End of reminder ---\n`;
|
|
187
|
+
if (agent.process.stdin?.writable) {
|
|
188
|
+
agent.process.stdin.write(`${reminder}\n`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
console.error('[agent] Failed to inject post-compact reminder:', err);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Filter out user messages (tool results) — they create noise in the feed
|
|
197
|
+
if (msgType === 'user') {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
emit(workspaceId, 'agent:output', parsed, agent.claudeSessionId);
|
|
201
|
+
// Detect brainstorming completion from parsed output
|
|
202
|
+
if (msgType === 'assistant' && Array.isArray(p.content)) {
|
|
203
|
+
const hasMarker = p.content.some((block) => {
|
|
204
|
+
const b = block;
|
|
205
|
+
return b.type === 'text' && typeof b.text === 'string' && b.text.includes('[BRAINSTORM_COMPLETE]');
|
|
206
|
+
});
|
|
207
|
+
if (hasMarker) {
|
|
208
|
+
try {
|
|
209
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
210
|
+
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.claudeSessionId);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
console.error('[agent] Failed to transition to executing:', err);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
// ── stderr — detect quota / rate limit errors ──
|
|
219
|
+
proc.stderr?.on('data', (data) => {
|
|
220
|
+
// I1: Don't process quota errors if the agent is already stopping or gone
|
|
221
|
+
const currentAgent = agents.get(workspaceId);
|
|
222
|
+
if (!currentAgent || currentAgent.status === 'stopping')
|
|
223
|
+
return;
|
|
224
|
+
const text = data.toString();
|
|
225
|
+
const lowerText = text.toLowerCase();
|
|
226
|
+
if (lowerText.includes('rate limit') || lowerText.includes('quota') || lowerText.includes('limit exceeded')) {
|
|
227
|
+
handleQuota(workspaceId, workingDir, agent.claudeSessionId);
|
|
228
|
+
}
|
|
229
|
+
// Also emit stderr for visibility
|
|
230
|
+
emit(workspaceId, 'agent:stderr', { content: text }, agent.claudeSessionId);
|
|
231
|
+
});
|
|
232
|
+
// ── process exit ──
|
|
233
|
+
proc.on('exit', (code) => {
|
|
234
|
+
// Clean up the .mcp.json file written before spawn
|
|
235
|
+
try {
|
|
236
|
+
fs.unlinkSync(mcpConfigPath);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// File may not exist (spawn failed) — ignore
|
|
240
|
+
}
|
|
241
|
+
// I3: Close readline interface to release the stream reference
|
|
242
|
+
agent.rl.close();
|
|
243
|
+
unregisterProcess(workspaceId);
|
|
244
|
+
agents.delete(workspaceId);
|
|
245
|
+
// Clean up retry state and inactivity timer
|
|
246
|
+
retryCounts.delete(workspaceId);
|
|
247
|
+
// C2: Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
|
|
248
|
+
const pendingKillTimer = killTimers.get(workspaceId);
|
|
249
|
+
if (pendingKillTimer) {
|
|
250
|
+
clearTimeout(pendingKillTimer);
|
|
251
|
+
killTimers.delete(workspaceId);
|
|
252
|
+
}
|
|
253
|
+
// Update agent_sessions row
|
|
254
|
+
{
|
|
255
|
+
const db = getDb();
|
|
256
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run(code === 0 ? 'completed' : 'error', new Date().toISOString(), agent.agentSessionId);
|
|
257
|
+
}
|
|
258
|
+
if (agent.status === 'stopping') {
|
|
259
|
+
// Clean stop requested
|
|
260
|
+
emit(workspaceId, 'agent:status', { status: 'stopped' }, agent.claudeSessionId);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// C1: Also clear backoff timers on non-stopping exit
|
|
264
|
+
const pendingBackoff = backoffTimers.get(workspaceId);
|
|
265
|
+
if (pendingBackoff) {
|
|
266
|
+
clearTimeout(pendingBackoff);
|
|
267
|
+
backoffTimers.delete(workspaceId);
|
|
268
|
+
}
|
|
269
|
+
if (code !== null && code !== 0) {
|
|
270
|
+
try {
|
|
271
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
console.error('[agent] Failed to update workspace status on exit:', err);
|
|
275
|
+
}
|
|
276
|
+
emit(workspaceId, 'agent:status', { status: 'error', exitCode: code }, agent.claudeSessionId);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
try {
|
|
280
|
+
updateWorkspaceStatus(workspaceId, 'completed');
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
console.error('[agent] Failed to update workspace status on exit:', err);
|
|
284
|
+
}
|
|
285
|
+
emit(workspaceId, 'agent:status', { status: 'completed' }, agent.claudeSessionId);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
// Store in agents map
|
|
289
|
+
agents.set(workspaceId, agent);
|
|
290
|
+
return agent;
|
|
291
|
+
}
|
|
292
|
+
// ── Stop agent ─────────────────────────────────────────────────────────────────
|
|
293
|
+
export function stopAgent(workspaceId) {
|
|
294
|
+
const agent = agents.get(workspaceId);
|
|
295
|
+
if (!agent) {
|
|
296
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
297
|
+
}
|
|
298
|
+
agent.status = 'stopping';
|
|
299
|
+
// Cancel any pending backoff timer
|
|
300
|
+
const timer = backoffTimers.get(workspaceId);
|
|
301
|
+
if (timer) {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
backoffTimers.delete(workspaceId);
|
|
304
|
+
}
|
|
305
|
+
// I3: Close readline interface now that we're stopping
|
|
306
|
+
try {
|
|
307
|
+
agent.rl.close();
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Ignore
|
|
311
|
+
}
|
|
312
|
+
// Send SIGTERM
|
|
313
|
+
try {
|
|
314
|
+
agent.process.kill('SIGTERM');
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Process may already be dead
|
|
318
|
+
}
|
|
319
|
+
// After 5s timeout, send SIGKILL if still running
|
|
320
|
+
const killTimer = setTimeout(() => {
|
|
321
|
+
// C2: Guard against race with natural exit — only act if this exact agent instance is still current
|
|
322
|
+
if (agents.get(workspaceId) !== agent) {
|
|
323
|
+
killTimers.delete(workspaceId);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
if (!agent.process.killed) {
|
|
328
|
+
agent.process.kill('SIGKILL');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// Ignore
|
|
333
|
+
}
|
|
334
|
+
killTimers.delete(workspaceId);
|
|
335
|
+
}, 5000);
|
|
336
|
+
// Don't keep the process alive for this timer
|
|
337
|
+
killTimer.unref?.();
|
|
338
|
+
killTimers.set(workspaceId, killTimer);
|
|
339
|
+
}
|
|
340
|
+
// ── Send message to agent stdin ────────────────────────────────────────────────
|
|
341
|
+
export function sendMessage(workspaceId, content) {
|
|
342
|
+
const agent = agents.get(workspaceId);
|
|
343
|
+
if (!agent) {
|
|
344
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
345
|
+
}
|
|
346
|
+
if (!agent.process.stdin?.writable) {
|
|
347
|
+
throw new Error(`Agent stdin not writable for workspace '${workspaceId}'`);
|
|
348
|
+
}
|
|
349
|
+
agent.process.stdin.write(`${content}\n`);
|
|
350
|
+
}
|
|
351
|
+
// ── Status queries ─────────────────────────────────────────────────────────────
|
|
352
|
+
export function getAgentStatus(workspaceId) {
|
|
353
|
+
const agent = agents.get(workspaceId);
|
|
354
|
+
return agent?.status ?? null;
|
|
355
|
+
}
|
|
356
|
+
export function getRunningCount() {
|
|
357
|
+
return agents.size;
|
|
358
|
+
}
|
|
359
|
+
export function getAvailableSkills() {
|
|
360
|
+
return availableSkills;
|
|
361
|
+
}
|
|
362
|
+
// ── Quota handling ─────────────────────────────────────────────────────────────
|
|
363
|
+
function handleQuota(workspaceId, workingDir, claudeSessionId) {
|
|
364
|
+
// Update workspace status
|
|
365
|
+
try {
|
|
366
|
+
updateWorkspaceStatus(workspaceId, 'quota');
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// May fail if transition is not valid
|
|
370
|
+
}
|
|
371
|
+
// Emit status event
|
|
372
|
+
emit(workspaceId, 'agent:status', { status: 'quota' }, claudeSessionId);
|
|
373
|
+
// Calculate backoff: 15min first, then 30min, then 60min cap
|
|
374
|
+
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
375
|
+
const backoffMinutes = Math.min(15 * 2 ** retryCount, 60);
|
|
376
|
+
const backoffMs = backoffMinutes * 60 * 1000;
|
|
377
|
+
retryCounts.set(workspaceId, retryCount + 1);
|
|
378
|
+
emit(workspaceId, 'agent:status', {
|
|
379
|
+
status: 'quota:backoff',
|
|
380
|
+
retryCount: retryCount + 1,
|
|
381
|
+
backoffMinutes,
|
|
382
|
+
}, claudeSessionId);
|
|
383
|
+
// Set timer to restart agent
|
|
384
|
+
const timer = setTimeout(() => {
|
|
385
|
+
backoffTimers.delete(workspaceId);
|
|
386
|
+
// Only restart if not already running or stopped
|
|
387
|
+
if (!agents.has(workspaceId)) {
|
|
388
|
+
try {
|
|
389
|
+
startAgent(workspaceId, workingDir, 'Continue the previous task where you left off.', undefined, true);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// Agent restart failed
|
|
393
|
+
emit(workspaceId, 'agent:status', { status: 'error', message: 'Quota retry failed' });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}, backoffMs);
|
|
397
|
+
timer.unref?.();
|
|
398
|
+
backoffTimers.set(workspaceId, timer);
|
|
399
|
+
}
|
|
400
|
+
// ── Testing utilities ──────────────────────────────────────────────────────────
|
|
401
|
+
/**
|
|
402
|
+
* Get the internal agents map — exposed for testing only.
|
|
403
|
+
* @internal
|
|
404
|
+
*/
|
|
405
|
+
export function _getAgents() {
|
|
406
|
+
return agents;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Get retry counts — exposed for testing only.
|
|
410
|
+
* @internal
|
|
411
|
+
*/
|
|
412
|
+
export function _getRetryCounts() {
|
|
413
|
+
return retryCounts;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Get backoff timers — exposed for testing only.
|
|
417
|
+
* @internal
|
|
418
|
+
*/
|
|
419
|
+
export function _getBackoffTimers() {
|
|
420
|
+
return backoffTimers;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Get kill timers — exposed for testing only.
|
|
424
|
+
* @internal
|
|
425
|
+
*/
|
|
426
|
+
export function _getKillTimers() {
|
|
427
|
+
return killTimers;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Get session IDs map — exposed for testing only.
|
|
431
|
+
* @internal
|
|
432
|
+
*/
|
|
433
|
+
export function _getSessionIds() {
|
|
434
|
+
return sessionIds;
|
|
435
|
+
}
|