kanon-cli 0.1.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.
@@ -0,0 +1,330 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs';
3
+ import { getStatusPath } from './config.js';
4
+ import { getActiveWorkers, killWorker, spawnClaude } from './claude.js';
5
+ import { buildBundlePrompt } from '../prompts/templates.js';
6
+
7
+ export class AgentController {
8
+ constructor(config = {}) {
9
+ this.checkInterval = (config.check_interval_seconds || 60) * 1000;
10
+ this.maxSessionMs = (config.max_session_minutes || 30) * 60 * 1000;
11
+ this.stuckTimeoutMs = (config.stuck_timeout_minutes || 5) * 60 * 1000;
12
+ this.autoRestartStuck = config.auto_restart_stuck ?? true;
13
+ this.queueMaxSize = config.queue_max_size || 20;
14
+ this.bundleQueue = config.bundle_queue ?? true;
15
+ this.projectConfig = config._projectConfig || {};
16
+
17
+ // Per-board max_concurrent: build a map from boardId -> limit
18
+ this._boardMaxConcurrent = new Map();
19
+ for (const board of this.projectConfig.boards || []) {
20
+ this._boardMaxConcurrent.set(board.id, board.claude?.max_concurrent || 1);
21
+ }
22
+ // Total max concurrent is sum of all active board limits
23
+ this.maxConcurrent = this._getTotalMaxConcurrent();
24
+
25
+ this.queue = []; // { cardId, prompt, config, priority, addedAt, card?, extraPrompt?, boardId? }
26
+ this.eventLog = []; // { timestamp, type, cardId, message }
27
+ this.completedWorkers = []; // last N completed workers with their output
28
+ this._boardWorkerCounts = new Map(); // boardId -> number of active workers
29
+ this.timer = null;
30
+ this.paused = false;
31
+ }
32
+
33
+ _getTotalMaxConcurrent() {
34
+ if (this._boardMaxConcurrent.size === 0) return 1;
35
+ let total = 0;
36
+ for (const limit of this._boardMaxConcurrent.values()) total += limit;
37
+ return Math.max(1, total);
38
+ }
39
+
40
+ _getBoardMaxConcurrent(boardId) {
41
+ return this._boardMaxConcurrent.get(boardId) || 1;
42
+ }
43
+
44
+ start() {
45
+ this.log('info', null, `Agent controller started (check every ${this.checkInterval / 1000}s)`);
46
+ this.tick(); // Run immediately
47
+ this.timer = setInterval(() => this.tick(), this.checkInterval);
48
+ }
49
+
50
+ stop() {
51
+ if (this.timer) {
52
+ clearInterval(this.timer);
53
+ this.timer = null;
54
+ }
55
+ this.log('info', null, 'Agent controller stopped');
56
+ }
57
+
58
+ tick() {
59
+ if (this.paused) return;
60
+
61
+ const workers = getActiveWorkers();
62
+
63
+ // Health checks
64
+ for (const [cardId, worker] of workers) {
65
+ const runtime = Date.now() - worker.startedAt;
66
+ const idleTime = Date.now() - worker.lastOutput;
67
+
68
+ // Max runtime exceeded
69
+ if (runtime > this.maxSessionMs) {
70
+ this.log('warn', cardId, `Worker exceeded max runtime (${Math.round(runtime / 60000)}min), killing`);
71
+ killWorker(cardId);
72
+ if (this.autoRestartStuck) {
73
+ this.log('info', cardId, 'Auto-restart queued');
74
+ // Re-queue with same context (if available)
75
+ }
76
+ continue;
77
+ }
78
+
79
+ // Stuck detection: no output for too long
80
+ if (idleTime > this.stuckTimeoutMs) {
81
+ this.log('warn', cardId, `Worker stuck (no output for ${Math.round(idleTime / 60000)}min), killing`);
82
+ killWorker(cardId);
83
+ continue;
84
+ }
85
+ }
86
+
87
+ // Process queue if slots available
88
+ this._processQueue();
89
+
90
+ // Write status snapshot
91
+ this._writeStatus();
92
+ }
93
+
94
+ /**
95
+ * Enqueue a new task. Returns true if queued, false if queue is full.
96
+ */
97
+ enqueue(cardId, prompt, claudeConfig, { priority = 0, card = null, extraPrompt = '', boardId = null } = {}) {
98
+ // Don't double-queue
99
+ if (this.queue.some(q => q.cardId === cardId)) {
100
+ this.log('info', cardId, 'Already in queue, skipping');
101
+ return false;
102
+ }
103
+
104
+ // Don't queue if already running
105
+ if (getActiveWorkers().has(cardId)) {
106
+ this.log('info', cardId, 'Already running, skipping');
107
+ return false;
108
+ }
109
+
110
+ if (this.queue.length >= this.queueMaxSize) {
111
+ this.log('warn', cardId, 'Queue full, dropping event');
112
+ return false;
113
+ }
114
+
115
+ this.queue.push({ cardId, prompt, config: claudeConfig, priority, addedAt: Date.now(), card, extraPrompt, boardId });
116
+ // Sort by priority (higher first), then by time (older first)
117
+ this.queue.sort((a, b) => b.priority - a.priority || a.addedAt - b.addedAt);
118
+
119
+ this.log('info', cardId, `Queued (position ${this.queue.findIndex(q => q.cardId === cardId) + 1}/${this.queue.length})`);
120
+
121
+ // Try to process immediately
122
+ this._processQueue();
123
+ return true;
124
+ }
125
+
126
+ _processQueue() {
127
+ const workers = getActiveWorkers();
128
+ if (this.queue.length === 0) return;
129
+
130
+ if (this.bundleQueue) {
131
+ // Bundle mode: group queue items by boardId, bundle within each board
132
+ const byBoard = new Map();
133
+ for (const task of this.queue) {
134
+ const key = task.boardId || '__default__';
135
+ if (!byBoard.has(key)) byBoard.set(key, []);
136
+ byBoard.get(key).push(task);
137
+ }
138
+
139
+ // Process one board group per available slot, respecting per-board limits
140
+ for (const [boardKey, tasks] of byBoard) {
141
+ const boardLimit = this._getBoardMaxConcurrent(boardKey);
142
+ const boardActive = this._boardWorkerCounts.get(boardKey) || 0;
143
+ if (boardActive >= boardLimit) continue;
144
+
145
+ // Remove these tasks from the queue
146
+ for (const t of tasks) {
147
+ const idx = this.queue.indexOf(t);
148
+ if (idx !== -1) this.queue.splice(idx, 1);
149
+ }
150
+
151
+ if (tasks.length === 1) {
152
+ this._incBoard(boardKey);
153
+ this._startSingleWorker(tasks[0], boardKey);
154
+ } else {
155
+ this._incBoard(boardKey);
156
+ this._startBundledWorker(tasks, boardKey);
157
+ }
158
+ }
159
+ } else {
160
+ // One task per worker, respecting per-board limits
161
+ const toProcess = [];
162
+ for (const task of this.queue) {
163
+ const boardKey = task.boardId || '__default__';
164
+ const boardLimit = this._getBoardMaxConcurrent(boardKey);
165
+ const boardActive = (this._boardWorkerCounts.get(boardKey) || 0) + toProcess.filter(t => (t.boardId || '__default__') === boardKey).length;
166
+ if (boardActive < boardLimit) {
167
+ toProcess.push(task);
168
+ }
169
+ }
170
+ for (const task of toProcess) {
171
+ const boardKey = task.boardId || '__default__';
172
+ const idx = this.queue.indexOf(task);
173
+ if (idx !== -1) this.queue.splice(idx, 1);
174
+ this._incBoard(boardKey);
175
+ this._startSingleWorker(task, boardKey);
176
+ }
177
+ }
178
+ }
179
+
180
+ _incBoard(boardKey) {
181
+ this._boardWorkerCounts.set(boardKey, (this._boardWorkerCounts.get(boardKey) || 0) + 1);
182
+ }
183
+
184
+ _decBoard(boardKey) {
185
+ const count = this._boardWorkerCounts.get(boardKey) || 1;
186
+ this._boardWorkerCounts.set(boardKey, Math.max(0, count - 1));
187
+ }
188
+
189
+ _startBundledWorker(tasks, boardKey) {
190
+ const cardIds = tasks.map(t => t.cardId);
191
+ const bundleId = `bundle-${Date.now()}`;
192
+
193
+ let combinedPrompt;
194
+ const hasCardData = tasks.every(t => t.card);
195
+
196
+ if (hasCardData) {
197
+ combinedPrompt = buildBundlePrompt(tasks, this.projectConfig);
198
+ } else {
199
+ combinedPrompt = tasks.map((t, i) =>
200
+ `## Task ${i + 1} (Card ${t.cardId})\n\n${t.prompt}`
201
+ ).join('\n\n---\n\n');
202
+ }
203
+
204
+ this.log('info', null, `Starting bundled worker for ${tasks.length} cards: ${cardIds.map(id => id.substring(0, 8)).join(', ')}`);
205
+ const worker = spawnClaude(bundleId, combinedPrompt, tasks[0].config);
206
+ if (worker?.process) {
207
+ worker.process.on('close', (code) => {
208
+ this._decBoard(boardKey);
209
+ this.completedWorkers.push({
210
+ cardId: bundleId,
211
+ cardIds,
212
+ bundled: true,
213
+ startedAt: worker.startedAt,
214
+ finishedAt: Date.now(),
215
+ runtimeMs: Date.now() - worker.startedAt,
216
+ exitCode: code,
217
+ output: worker.output.slice(-50),
218
+ });
219
+ if (this.completedWorkers.length > 10) this.completedWorkers.shift();
220
+ this.log(code === 0 ? 'info' : 'warn', null, `Bundled worker finished (exit ${code}, ${Math.round((Date.now() - worker.startedAt) / 1000)}s) — cards: ${cardIds.map(id => id.substring(0, 8)).join(', ')}`);
221
+ this._processQueue();
222
+ });
223
+ }
224
+ }
225
+
226
+ _startSingleWorker(task, boardKey) {
227
+ this.log('info', task.cardId, 'Starting worker from queue');
228
+ const worker = spawnClaude(task.cardId, task.prompt, task.config);
229
+ if (worker?.process) {
230
+ worker.process.on('close', (code) => {
231
+ if (boardKey) this._decBoard(boardKey);
232
+ this.completedWorkers.push({
233
+ cardId: task.cardId,
234
+ startedAt: worker.startedAt,
235
+ finishedAt: Date.now(),
236
+ runtimeMs: Date.now() - worker.startedAt,
237
+ exitCode: code,
238
+ output: worker.output.slice(-50),
239
+ });
240
+ if (this.completedWorkers.length > 10) this.completedWorkers.shift();
241
+ this.log(code === 0 ? 'info' : 'warn', task.cardId, `Worker finished (exit ${code}, ${Math.round((Date.now() - worker.startedAt) / 1000)}s)`);
242
+ this._processQueue();
243
+ });
244
+ }
245
+ }
246
+
247
+ _writeStatus() {
248
+ try {
249
+ const workers = getActiveWorkers();
250
+ const status = {
251
+ timestamp: new Date().toISOString(),
252
+ paused: this.paused,
253
+ workers: Array.from(workers.entries()).map(([cardId, w]) => ({
254
+ cardId,
255
+ startedAt: new Date(w.startedAt).toISOString(),
256
+ runtimeMs: Date.now() - w.startedAt,
257
+ lastOutputMs: Date.now() - w.lastOutput,
258
+ outputTail: w.output.slice(-30),
259
+ exitCode: w.exitCode,
260
+ })),
261
+ queue: this.queue.map(q => ({
262
+ cardId: q.cardId,
263
+ priority: q.priority,
264
+ waitingMs: Date.now() - q.addedAt,
265
+ })),
266
+ recentEvents: this.eventLog.slice(-20),
267
+ };
268
+ fs.writeFileSync(getStatusPath(), JSON.stringify(status, null, 2));
269
+ } catch {
270
+ // Non-critical, ignore
271
+ }
272
+ }
273
+
274
+ log(type, cardId, message, { triggered, boardId } = {}) {
275
+ const entry = {
276
+ timestamp: new Date().toISOString(),
277
+ type,
278
+ cardId,
279
+ message,
280
+ };
281
+ if (triggered !== undefined) entry.triggered = triggered;
282
+ if (boardId) entry.boardId = boardId;
283
+ this.eventLog.push(entry);
284
+ // Keep last 200 entries
285
+ if (this.eventLog.length > 200) this.eventLog.shift();
286
+
287
+ const prefix = cardId ? `[${cardId.substring(0, 8)}]` : '[admin]';
288
+ const color = type === 'warn' ? chalk.yellow : type === 'error' ? chalk.red : chalk.dim;
289
+ console.log(color(`${prefix} ${message}`));
290
+ }
291
+
292
+ getStatus() {
293
+ const workers = getActiveWorkers();
294
+ return {
295
+ paused: this.paused,
296
+ activeWorkers: workers.size,
297
+ queueLength: this.queue.length,
298
+ maxConcurrent: this.maxConcurrent,
299
+ workers: Array.from(workers.entries()).map(([cardId, w]) => ({
300
+ cardId,
301
+ runtimeMs: Date.now() - w.startedAt,
302
+ lastOutputMs: Date.now() - w.lastOutput,
303
+ outputTail: w.output.slice(-30),
304
+ })),
305
+ queue: this.queue.map(q => ({
306
+ cardId: q.cardId,
307
+ priority: q.priority,
308
+ waitingMs: Date.now() - q.addedAt,
309
+ })),
310
+ completedWorkers: this.completedWorkers.slice(-10),
311
+ };
312
+ }
313
+
314
+ getEventLog(limit = 50) {
315
+ return this.eventLog.slice(-limit);
316
+ }
317
+
318
+ pause() {
319
+ this.paused = true;
320
+ this.log('info', null, 'Agent controller paused');
321
+ }
322
+
323
+ resume() {
324
+ this.paused = false;
325
+ this.log('info', null, 'Agent controller resumed');
326
+ this._processQueue();
327
+ }
328
+ }
329
+
330
+ export default AgentController;
package/src/lib/api.js ADDED
@@ -0,0 +1,225 @@
1
+ import { getToken, getServerUrl, loadGlobalConfig, saveGlobalConfig } from './config.js';
2
+
3
+ class KanonAPI {
4
+ constructor() {
5
+ this._token = null;
6
+ }
7
+
8
+ get token() {
9
+ if (!this._token) this._token = getToken();
10
+ return this._token;
11
+ }
12
+
13
+ set token(val) {
14
+ this._token = val;
15
+ }
16
+
17
+ get baseUrl() {
18
+ return getServerUrl() + '/api';
19
+ }
20
+
21
+ async request(method, path, body = null, retried = false) {
22
+ const url = `${this.baseUrl}${path}`;
23
+ const headers = { 'Content-Type': 'application/json' };
24
+ if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
25
+
26
+ const opts = { method, headers };
27
+ if (body) opts.body = JSON.stringify(body);
28
+
29
+ const res = await fetch(url, opts);
30
+
31
+ // Auto re-login on 401
32
+ if (res.status === 401 && !retried) {
33
+ const refreshed = await this.tryRefreshToken();
34
+ if (refreshed) return this.request(method, path, body, true);
35
+ }
36
+
37
+ if (!res.ok) {
38
+ const text = await res.text();
39
+ let message;
40
+ try { message = JSON.parse(text).error; } catch { message = text; }
41
+ throw new Error(`${method} ${path} failed (${res.status}): ${message}`);
42
+ }
43
+
44
+ const text = await res.text();
45
+ if (!text) return {};
46
+ return JSON.parse(text);
47
+ }
48
+
49
+ async tryRefreshToken() {
50
+ const config = loadGlobalConfig();
51
+ if (!config.email || !config.password) return false;
52
+ try {
53
+ const res = await fetch(`${this.baseUrl}/auth/login`, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ email: config.email, password: config.password }),
57
+ });
58
+ if (!res.ok) return false;
59
+ const data = await res.json();
60
+ this.token = data.token;
61
+ saveGlobalConfig({ ...config, token: data.token });
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // --- Auth ---
69
+
70
+ async login(email, password) {
71
+ const res = await fetch(`${this.baseUrl}/auth/login`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ email, password }),
75
+ });
76
+ if (!res.ok) {
77
+ const data = await res.json().catch(() => ({}));
78
+ throw new Error(data.error || `Login failed (${res.status})`);
79
+ }
80
+ return res.json();
81
+ }
82
+
83
+ // --- Teams & Boards ---
84
+
85
+ async getTeams() {
86
+ return this.request('GET', '/teams');
87
+ }
88
+
89
+ async getBoard(boardId, { cardFields = 'essential', cardLimit = 20 } = {}) {
90
+ return this.request('GET', `/boards/${boardId}?card_fields=${cardFields}&card_limit=${cardLimit}`);
91
+ }
92
+
93
+ // --- Cards ---
94
+
95
+ async getCard(cardId) {
96
+ return this.request('GET', `/cards/${cardId}`);
97
+ }
98
+
99
+ async updateCard(cardId, data) {
100
+ return this.request('PUT', `/cards/${cardId}?minimal=true`, data);
101
+ }
102
+
103
+ async moveCard(cardId, listId, position = 0) {
104
+ return this.request('PUT', `/cards/${cardId}/move`, { listId, position });
105
+ }
106
+
107
+ async createCard(listId, data) {
108
+ return this.request('POST', `/lists/${listId}/cards`, data);
109
+ }
110
+
111
+ // --- Labels ---
112
+
113
+ async addLabel(cardId, labelId) {
114
+ return this.request('POST', `/cards/${cardId}/labels/${labelId}`);
115
+ }
116
+
117
+ async removeLabel(cardId, labelId) {
118
+ return this.request('DELETE', `/cards/${cardId}/labels/${labelId}`);
119
+ }
120
+
121
+ // --- Comments ---
122
+
123
+ async addComment(cardId, text) {
124
+ return this.request('POST', `/cards/${cardId}/comments`, { text });
125
+ }
126
+
127
+ // --- Assignees ---
128
+
129
+ async addAssignee(cardId, userId) {
130
+ return this.request('POST', `/cards/${cardId}/assignees/${userId}`);
131
+ }
132
+
133
+ async removeAssignee(cardId, userId) {
134
+ return this.request('DELETE', `/cards/${cardId}/assignees/${userId}`);
135
+ }
136
+
137
+ // --- Checklists ---
138
+
139
+ async createChecklist(cardId, title) {
140
+ return this.request('POST', `/cards/${cardId}/checklists`, { title });
141
+ }
142
+
143
+ async addChecklistItem(checklistId, text) {
144
+ return this.request('POST', `/checklists/${checklistId}/items`, { text });
145
+ }
146
+
147
+ async toggleChecklistItem(itemId) {
148
+ return this.request('POST', `/checklist-items/${itemId}/toggle`);
149
+ }
150
+
151
+ // --- Archive ---
152
+ async archiveCard(cardId) {
153
+ return this.request('PUT', `/cards/${cardId}/archive`);
154
+ }
155
+
156
+ // --- Lists ---
157
+ async createList(boardId, title, position) {
158
+ const body = { title };
159
+ if (position !== undefined) body.position = position;
160
+ return this.request('POST', `/boards/${boardId}/lists`, body);
161
+ }
162
+
163
+ async updateList(listId, data) {
164
+ return this.request('PUT', `/lists/${listId}`, data);
165
+ }
166
+
167
+ // --- Board Labels ---
168
+ async createBoardLabel(boardId, name, color) {
169
+ return this.request('POST', `/boards/${boardId}/labels`, { name, color });
170
+ }
171
+
172
+ // --- Checklist Items ---
173
+ async updateChecklistItem(itemId, data) {
174
+ return this.request('PUT', `/checklist-items/${itemId}`, data);
175
+ }
176
+
177
+ async deleteChecklistItem(itemId) {
178
+ return this.request('DELETE', `/checklist-items/${itemId}`);
179
+ }
180
+
181
+ // --- Attachments (mounted under /boards/ on the server) ---
182
+ async getAttachments(cardId) {
183
+ return this.request('GET', `/boards/cards/${cardId}/attachments`);
184
+ }
185
+
186
+ async getAttachmentUrl(cardId, attachmentId) {
187
+ return this.request('GET', `/boards/cards/${cardId}/attachments/${attachmentId}/url`);
188
+ }
189
+
190
+ async deleteAttachment(cardId, attachmentId) {
191
+ return this.request('DELETE', `/boards/cards/${cardId}/attachments/${attachmentId}`);
192
+ }
193
+
194
+ async uploadAttachment(cardId, filePath) {
195
+ const fs = await import('fs');
196
+ const path = await import('path');
197
+ const filename = path.default.basename(filePath);
198
+ const fileBuffer = fs.default.readFileSync(filePath);
199
+
200
+ const boundary = '----KanonCLI' + Date.now();
201
+ const body = Buffer.concat([
202
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`),
203
+ fileBuffer,
204
+ Buffer.from(`\r\n--${boundary}--\r\n`),
205
+ ]);
206
+
207
+ const url = `${this.baseUrl}/boards/cards/${cardId}/attachments`;
208
+ const headers = {
209
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
210
+ 'Authorization': `Bearer ${this.token}`,
211
+ };
212
+
213
+ const res = await fetch(url, { method: 'POST', headers, body });
214
+ if (!res.ok) {
215
+ const text = await res.text();
216
+ let msg;
217
+ try { msg = JSON.parse(text).error; } catch { msg = text; }
218
+ throw new Error(`Upload failed (${res.status}): ${msg}`);
219
+ }
220
+ return res.json();
221
+ }
222
+ }
223
+
224
+ export const api = new KanonAPI();
225
+ export default api;