kanon-cli 0.1.0 → 0.1.2
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/bin/kanon.js +134 -1
- package/package.json +5 -3
- package/src/commands/canvas.js +45 -0
- package/src/commands/card.js +14 -1
- package/src/commands/cards.js +3 -2
- package/src/commands/label.js +1 -1
- package/src/commands/list.js +1 -1
- package/src/commands/note.js +79 -0
- package/src/commands/sheet.js +137 -0
- package/src/commands/subcard.js +59 -0
- package/src/commands/watch.js +9 -0
- package/src/dashboard/dist/assets/index-ClOAcx9M.css +1 -0
- package/src/dashboard/dist/assets/index-DrHjrBfj.js +216 -0
- package/src/dashboard/dist/index.html +2 -2
- package/src/dashboard/server/index.js +36 -1
- package/src/dashboard/server/proxy.js +5 -1
- package/src/dashboard/server/settings.js +251 -11
- package/src/lib/admin.js +29 -3
- package/src/lib/api.js +55 -0
- package/src/lib/claude.js +65 -9
- package/src/prompts/templates.js +55 -19
- package/src/dashboard/dist/assets/index-Dcbpx-Xz.js +0 -186
- package/src/dashboard/dist/assets/index-DhFfv70f.css +0 -1
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Kanon Dashboard</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DrHjrBfj.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-ClOAcx9M.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body class="h-full text-gray-100">
|
|
11
11
|
<div id="root" class="h-full"></div>
|
|
@@ -12,13 +12,48 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
12
12
|
|
|
13
13
|
export function createDashboardServer(port = 3737) {
|
|
14
14
|
const app = express();
|
|
15
|
-
app.use(express.json());
|
|
15
|
+
app.use(express.json({ limit: '5mb' }));
|
|
16
16
|
|
|
17
17
|
// API routes
|
|
18
18
|
app.use('/api/kanon', createProxyRoutes());
|
|
19
19
|
app.use('/api/settings', createSettingsRoutes());
|
|
20
20
|
app.use('/api/agent', createAgentRoutes());
|
|
21
21
|
|
|
22
|
+
// GET /api/version — current version + check for updates
|
|
23
|
+
app.get('/api/version', async (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const pkgPath = path.resolve(__dirname, '../../../package.json');
|
|
26
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
27
|
+
const current = pkg.version;
|
|
28
|
+
|
|
29
|
+
let latest = current;
|
|
30
|
+
try {
|
|
31
|
+
const resp = await fetch('https://registry.npmjs.org/kanon-cli/latest', {
|
|
32
|
+
headers: { 'Accept': 'application/json' },
|
|
33
|
+
signal: AbortSignal.timeout(3000),
|
|
34
|
+
});
|
|
35
|
+
if (resp.ok) {
|
|
36
|
+
const data = await resp.json();
|
|
37
|
+
latest = data.version || current;
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
|
|
41
|
+
res.json({ current, latest, updateAvailable: latest !== current });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
res.status(500).json({ error: err.message });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// POST /api/update — run npm update -g kanon-cli
|
|
48
|
+
app.post('/api/update', async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
execSync('npm install -g kanon-cli@latest', { stdio: 'pipe', timeout: 30000 });
|
|
51
|
+
res.json({ ok: true });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
res.status(500).json({ error: err.stderr?.toString() || err.message });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
22
57
|
// POST /api/restart — restart server, optionally rebuild dashboard first
|
|
23
58
|
app.post('/api/restart', async (req, res) => {
|
|
24
59
|
const rebuild = req.query.rebuild === '1';
|
|
@@ -10,8 +10,12 @@ export function createProxyRoutes() {
|
|
|
10
10
|
const serverUrl = getServerUrl();
|
|
11
11
|
|
|
12
12
|
try {
|
|
13
|
+
if (!config.token) {
|
|
14
|
+
return res.status(401).json({ error: 'Agent not configured. Set up your agent on the Credentials page first.' });
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
const headers = { 'Content-Type': 'application/json' };
|
|
14
|
-
|
|
18
|
+
headers['Authorization'] = `Bearer ${config.token}`;
|
|
15
19
|
|
|
16
20
|
const opts = { method, headers };
|
|
17
21
|
if (req.body && Object.keys(req.body).length) {
|
|
@@ -172,32 +172,164 @@ export function createSettingsRoutes() {
|
|
|
172
172
|
}
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
// GET /api/settings/credentials — returns global config
|
|
176
|
-
router.get('/credentials', (req, res) => {
|
|
175
|
+
// GET /api/settings/credentials — returns global config + agent profile from server
|
|
176
|
+
router.get('/credentials', async (req, res) => {
|
|
177
177
|
const config = loadGlobalConfig();
|
|
178
|
-
|
|
178
|
+
const base = {
|
|
179
179
|
server_url: config.server_url || '',
|
|
180
180
|
email: config.email || '',
|
|
181
|
-
password: config.password
|
|
181
|
+
password: config.password || '',
|
|
182
182
|
token: config.token || '',
|
|
183
183
|
user_name: config.user_name || '',
|
|
184
184
|
user_id: config.user_id || '',
|
|
185
|
-
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Fetch agent profile from server if we have a token
|
|
188
|
+
if (config.token) {
|
|
189
|
+
try {
|
|
190
|
+
const serverUrl = getServerUrl();
|
|
191
|
+
const profileRes = await fetch(`${serverUrl}/api/auth/me`, {
|
|
192
|
+
headers: { 'Authorization': `Bearer ${config.token}` },
|
|
193
|
+
});
|
|
194
|
+
if (profileRes.ok) {
|
|
195
|
+
const profile = await profileRes.json();
|
|
196
|
+
const user = profile.user || profile;
|
|
197
|
+
base.user_name = user.name || base.user_name;
|
|
198
|
+
base.color = user.color || '#6366f1';
|
|
199
|
+
base.avatar = user.avatar || '';
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
res.json(base);
|
|
186
205
|
});
|
|
187
206
|
|
|
188
207
|
// PUT /api/settings/credentials — update global config
|
|
189
208
|
router.put('/credentials', (req, res) => {
|
|
190
209
|
try {
|
|
191
210
|
const current = loadGlobalConfig();
|
|
192
|
-
const
|
|
211
|
+
const email = req.body.email ?? current.email ?? '';
|
|
212
|
+
// If email is cleared, also clear auth fields
|
|
213
|
+
const clearAuth = !email;
|
|
214
|
+
saveGlobalConfig({
|
|
215
|
+
server_url: req.body.server_url ?? current.server_url ?? '',
|
|
216
|
+
email,
|
|
217
|
+
password: clearAuth ? '' : (req.body.password ?? current.password ?? ''),
|
|
218
|
+
token: clearAuth ? '' : (req.body.token ?? current.token ?? ''),
|
|
219
|
+
user_id: clearAuth ? '' : (req.body.user_id ?? current.user_id ?? ''),
|
|
220
|
+
user_name: clearAuth ? '' : (req.body.user_name ?? current.user_name ?? ''),
|
|
221
|
+
});
|
|
222
|
+
res.json({ ok: true });
|
|
223
|
+
} catch (err) {
|
|
224
|
+
res.status(500).json({ error: err.message });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// PUT /api/settings/agent-profile — update agent name and color on Kanon server
|
|
229
|
+
router.put('/agent-profile', async (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
const config = loadGlobalConfig();
|
|
232
|
+
if (!config.token) return res.status(401).json({ error: 'Not logged in' });
|
|
233
|
+
const serverUrl = getServerUrl();
|
|
234
|
+
|
|
235
|
+
const { name, color } = req.body;
|
|
236
|
+
const profileRes = await fetch(`${serverUrl}/api/auth/me`, {
|
|
237
|
+
method: 'PUT',
|
|
238
|
+
headers: {
|
|
239
|
+
'Content-Type': 'application/json',
|
|
240
|
+
'Authorization': `Bearer ${config.token}`,
|
|
241
|
+
},
|
|
242
|
+
body: JSON.stringify({ name, color }),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (!profileRes.ok) {
|
|
246
|
+
const data = await profileRes.json().catch(() => ({}));
|
|
247
|
+
return res.status(profileRes.status).json({ error: data.error || 'Failed to update profile' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Update local config name
|
|
251
|
+
if (name) saveGlobalConfig({ ...config, user_name: name });
|
|
252
|
+
|
|
253
|
+
res.json({ ok: true });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
res.status(500).json({ error: err.message });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// POST /api/settings/agent-avatar — upload agent avatar to Kanon server
|
|
260
|
+
router.post('/agent-avatar', async (req, res) => {
|
|
261
|
+
try {
|
|
262
|
+
const config = loadGlobalConfig();
|
|
263
|
+
if (!config.token) return res.status(401).json({ error: 'Not logged in' });
|
|
264
|
+
const serverUrl = getServerUrl();
|
|
265
|
+
|
|
266
|
+
const { image } = req.body;
|
|
267
|
+
if (!image) return res.status(400).json({ error: 'No image data' });
|
|
268
|
+
|
|
269
|
+
const avatarRes = await fetch(`${serverUrl}/api/auth/me/avatar`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: {
|
|
272
|
+
'Content-Type': 'application/json',
|
|
273
|
+
'Authorization': `Bearer ${config.token}`,
|
|
274
|
+
},
|
|
275
|
+
body: JSON.stringify({ image }),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (!avatarRes.ok) {
|
|
279
|
+
const data = await avatarRes.json().catch(() => ({}));
|
|
280
|
+
return res.status(avatarRes.status).json({ error: data.error || 'Failed to upload avatar' });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const data = await avatarRes.json();
|
|
284
|
+
res.json({ ok: true, avatar: data.user?.avatar || data.avatar });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
res.status(500).json({ error: err.message });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// DELETE /api/settings/agent-avatar — remove agent avatar
|
|
291
|
+
router.delete('/agent-avatar', async (req, res) => {
|
|
292
|
+
try {
|
|
293
|
+
const config = loadGlobalConfig();
|
|
294
|
+
if (!config.token) return res.status(401).json({ error: 'Not logged in' });
|
|
295
|
+
const serverUrl = getServerUrl();
|
|
296
|
+
|
|
297
|
+
const delRes = await fetch(`${serverUrl}/api/auth/me/avatar`, {
|
|
298
|
+
method: 'DELETE',
|
|
299
|
+
headers: { 'Authorization': `Bearer ${config.token}` },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (!delRes.ok) {
|
|
303
|
+
const data = await delRes.json().catch(() => ({}));
|
|
304
|
+
return res.status(delRes.status).json({ error: data.error || 'Failed to remove avatar' });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
res.json({ ok: true });
|
|
308
|
+
} catch (err) {
|
|
309
|
+
res.status(500).json({ error: err.message });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// DELETE /api/settings/agent-account — delete agent account from Kanon server + clear local config
|
|
314
|
+
router.delete('/agent-account', async (req, res) => {
|
|
315
|
+
try {
|
|
316
|
+
const config = loadGlobalConfig();
|
|
317
|
+
if (!config.token) return res.status(401).json({ error: 'Not logged in' });
|
|
318
|
+
const serverUrl = getServerUrl();
|
|
319
|
+
|
|
320
|
+
const delRes = await fetch(`${serverUrl}/api/auth/me`, {
|
|
321
|
+
method: 'DELETE',
|
|
322
|
+
headers: { 'Authorization': `Bearer ${config.token}` },
|
|
323
|
+
});
|
|
193
324
|
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
update.password = req.body.password;
|
|
325
|
+
if (!delRes.ok) {
|
|
326
|
+
const data = await delRes.json().catch(() => ({}));
|
|
327
|
+
return res.status(delRes.status).json({ error: data.error || 'Failed to delete account' });
|
|
198
328
|
}
|
|
199
329
|
|
|
200
|
-
|
|
330
|
+
// Clear local credentials
|
|
331
|
+
saveGlobalConfig({ server_url: config.server_url });
|
|
332
|
+
|
|
201
333
|
res.json({ ok: true });
|
|
202
334
|
} catch (err) {
|
|
203
335
|
res.status(500).json({ error: err.message });
|
|
@@ -232,5 +364,113 @@ export function createSettingsRoutes() {
|
|
|
232
364
|
}
|
|
233
365
|
});
|
|
234
366
|
|
|
367
|
+
// POST /api/settings/provision-agent — log in as user, create agent, save credentials
|
|
368
|
+
router.post('/provision-agent', async (req, res) => {
|
|
369
|
+
try {
|
|
370
|
+
const { email, password, agent_name, team_ids } = req.body;
|
|
371
|
+
const serverUrl = getServerUrl();
|
|
372
|
+
|
|
373
|
+
if (!email || !password || !agent_name) {
|
|
374
|
+
return res.status(400).json({ error: 'Email, password, and agent name are required' });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 1. Log in with user's personal credentials
|
|
378
|
+
const loginRes = await fetch(`${serverUrl}/api/auth/login`, {
|
|
379
|
+
method: 'POST',
|
|
380
|
+
headers: { 'Content-Type': 'application/json' },
|
|
381
|
+
body: JSON.stringify({ email, password }),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (!loginRes.ok) {
|
|
385
|
+
const data = await loginRes.json().catch(() => ({}));
|
|
386
|
+
return res.status(401).json({ error: data.error || 'Login failed. Check your email and password.' });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const loginData = await loginRes.json();
|
|
390
|
+
const userToken = loginData.token;
|
|
391
|
+
|
|
392
|
+
// 2. Provision agent via Kanon API
|
|
393
|
+
const provisionRes = await fetch(`${serverUrl}/api/agents/provision`, {
|
|
394
|
+
method: 'POST',
|
|
395
|
+
headers: {
|
|
396
|
+
'Content-Type': 'application/json',
|
|
397
|
+
'Authorization': `Bearer ${userToken}`,
|
|
398
|
+
},
|
|
399
|
+
body: JSON.stringify({ name: agent_name, team_ids: team_ids || [] }),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (!provisionRes.ok) {
|
|
403
|
+
const data = await provisionRes.json().catch(() => ({}));
|
|
404
|
+
return res.status(provisionRes.status).json({ error: data.error || 'Failed to provision agent' });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const provisionData = await provisionRes.json();
|
|
408
|
+
const agent = provisionData.agent;
|
|
409
|
+
|
|
410
|
+
// 3. Save agent credentials to global config
|
|
411
|
+
const current = loadGlobalConfig();
|
|
412
|
+
saveGlobalConfig({
|
|
413
|
+
...current,
|
|
414
|
+
server_url: serverUrl,
|
|
415
|
+
email: agent.email,
|
|
416
|
+
password: agent.password || current.password,
|
|
417
|
+
token: agent.token,
|
|
418
|
+
user_id: agent.id,
|
|
419
|
+
user_name: agent.name,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
res.json({
|
|
423
|
+
ok: true,
|
|
424
|
+
created: provisionData.created,
|
|
425
|
+
agent: {
|
|
426
|
+
id: agent.id,
|
|
427
|
+
email: agent.email,
|
|
428
|
+
name: agent.name,
|
|
429
|
+
teams: agent.teams || [],
|
|
430
|
+
},
|
|
431
|
+
skippedTeams: provisionData.skippedTeams || [],
|
|
432
|
+
});
|
|
433
|
+
} catch (err) {
|
|
434
|
+
res.status(500).json({ error: err.message });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// POST /api/settings/provision-teams — get user's teams for provisioning UI
|
|
439
|
+
router.post('/provision-teams', async (req, res) => {
|
|
440
|
+
try {
|
|
441
|
+
const { email, password } = req.body;
|
|
442
|
+
const serverUrl = getServerUrl();
|
|
443
|
+
|
|
444
|
+
// Log in to get token
|
|
445
|
+
const loginRes = await fetch(`${serverUrl}/api/auth/login`, {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
headers: { 'Content-Type': 'application/json' },
|
|
448
|
+
body: JSON.stringify({ email, password }),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (!loginRes.ok) {
|
|
452
|
+
const data = await loginRes.json().catch(() => ({}));
|
|
453
|
+
return res.status(401).json({ error: data.error || 'Login failed' });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const loginData = await loginRes.json();
|
|
457
|
+
|
|
458
|
+
// Fetch teams
|
|
459
|
+
const teamsRes = await fetch(`${serverUrl}/api/agents/teams`, {
|
|
460
|
+
headers: { 'Authorization': `Bearer ${loginData.token}` },
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
if (!teamsRes.ok) {
|
|
464
|
+
const data = await teamsRes.json().catch(() => ({}));
|
|
465
|
+
return res.status(teamsRes.status).json({ error: data.error || `Failed to fetch teams. Please try again later.` });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const teamsData = await teamsRes.json();
|
|
469
|
+
res.json(teamsData);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
res.status(500).json({ error: err.message });
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
235
475
|
return router;
|
|
236
476
|
}
|
package/src/lib/admin.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import fs from 'fs';
|
|
3
|
-
import { getStatusPath } from './config.js';
|
|
3
|
+
import { getStatusPath, getServerUrl, getToken } from './config.js';
|
|
4
4
|
import { getActiveWorkers, killWorker, spawnClaude } from './claude.js';
|
|
5
5
|
import { buildBundlePrompt } from '../prompts/templates.js';
|
|
6
6
|
|
|
7
|
+
/** Fire-and-forget POST to server for agent status changes */
|
|
8
|
+
function _notifyAgentStatus(cardId, boardId, action, extra = {}) {
|
|
9
|
+
const serverUrl = getServerUrl();
|
|
10
|
+
const token = getToken();
|
|
11
|
+
if (!serverUrl || !token || !boardId) return;
|
|
12
|
+
fetch(`${serverUrl}/api/agents/status`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
15
|
+
body: JSON.stringify({ cardId, boardId, action, ...extra }),
|
|
16
|
+
}).catch(() => {});
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
export class AgentController {
|
|
8
20
|
constructor(config = {}) {
|
|
9
21
|
this.checkInterval = (config.check_interval_seconds || 60) * 1000;
|
|
@@ -113,6 +125,7 @@ export class AgentController {
|
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
this.queue.push({ cardId, prompt, config: claudeConfig, priority, addedAt: Date.now(), card, extraPrompt, boardId });
|
|
128
|
+
_notifyAgentStatus(cardId, boardId, 'queue');
|
|
116
129
|
// Sort by priority (higher first), then by time (older first)
|
|
117
130
|
this.queue.sort((a, b) => b.priority - a.priority || a.addedAt - b.addedAt);
|
|
118
131
|
|
|
@@ -202,8 +215,16 @@ export class AgentController {
|
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
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);
|
|
218
|
+
const worker = spawnClaude(bundleId, combinedPrompt, { ...tasks[0].config, boardId: tasks[0].boardId, outputCardId: tasks[0].cardId });
|
|
206
219
|
if (worker?.process) {
|
|
220
|
+
// Notify primary card as active, others as bundled
|
|
221
|
+
for (const t of tasks) {
|
|
222
|
+
const isPrimary = t === tasks[0];
|
|
223
|
+
_notifyAgentStatus(t.cardId, t.boardId, 'start', {
|
|
224
|
+
bundleId,
|
|
225
|
+
primaryCardId: isPrimary ? null : tasks[0].cardId,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
207
228
|
worker.process.on('close', (code) => {
|
|
208
229
|
this._decBoard(boardKey);
|
|
209
230
|
this.completedWorkers.push({
|
|
@@ -217,6 +238,9 @@ export class AgentController {
|
|
|
217
238
|
output: worker.output.slice(-50),
|
|
218
239
|
});
|
|
219
240
|
if (this.completedWorkers.length > 10) this.completedWorkers.shift();
|
|
241
|
+
for (const cid of cardIds) {
|
|
242
|
+
_notifyAgentStatus(cid, tasks[0].boardId, 'stop', { exitCode: code });
|
|
243
|
+
}
|
|
220
244
|
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
245
|
this._processQueue();
|
|
222
246
|
});
|
|
@@ -225,8 +249,9 @@ export class AgentController {
|
|
|
225
249
|
|
|
226
250
|
_startSingleWorker(task, boardKey) {
|
|
227
251
|
this.log('info', task.cardId, 'Starting worker from queue');
|
|
228
|
-
const worker = spawnClaude(task.cardId, task.prompt, task.config);
|
|
252
|
+
const worker = spawnClaude(task.cardId, task.prompt, { ...task.config, boardId: task.boardId });
|
|
229
253
|
if (worker?.process) {
|
|
254
|
+
_notifyAgentStatus(task.cardId, task.boardId, 'start');
|
|
230
255
|
worker.process.on('close', (code) => {
|
|
231
256
|
if (boardKey) this._decBoard(boardKey);
|
|
232
257
|
this.completedWorkers.push({
|
|
@@ -238,6 +263,7 @@ export class AgentController {
|
|
|
238
263
|
output: worker.output.slice(-50),
|
|
239
264
|
});
|
|
240
265
|
if (this.completedWorkers.length > 10) this.completedWorkers.shift();
|
|
266
|
+
_notifyAgentStatus(task.cardId, task.boardId, 'stop', { exitCode: code });
|
|
241
267
|
this.log(code === 0 ? 'info' : 'warn', task.cardId, `Worker finished (exit ${code}, ${Math.round((Date.now() - worker.startedAt) / 1000)}s)`);
|
|
242
268
|
this._processQueue();
|
|
243
269
|
});
|
package/src/lib/api.js
CHANGED
|
@@ -219,6 +219,61 @@ class KanonAPI {
|
|
|
219
219
|
}
|
|
220
220
|
return res.json();
|
|
221
221
|
}
|
|
222
|
+
// --- Subcards ---
|
|
223
|
+
|
|
224
|
+
async setParent(cardId, parentCardId) {
|
|
225
|
+
return this.request('PUT', `/cards/${cardId}/set-parent`, { parentCardId });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Sheets ---
|
|
229
|
+
|
|
230
|
+
async getSheet(cardId) {
|
|
231
|
+
return this.request('GET', `/cards/${cardId}/sheet`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async createSheet(cardId) {
|
|
235
|
+
return this.request('POST', `/cards/${cardId}/sheet`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async updateSheet(cardId, data) {
|
|
239
|
+
return this.request('PUT', `/cards/${cardId}/sheet`, { data });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async updateSheetCell(cardId, { tab, row, col, value }) {
|
|
243
|
+
return this.request('PUT', `/cards/${cardId}/sheet/cell`, { tab, row, col, value });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async deleteSheet(cardId) {
|
|
247
|
+
return this.request('DELETE', `/cards/${cardId}/sheet`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- Notes (plaintext) ---
|
|
251
|
+
|
|
252
|
+
async getNoteText(cardId) {
|
|
253
|
+
return this.request('GET', `/cards/${cardId}/note/text`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async setNoteText(cardId, text) {
|
|
257
|
+
return this.request('PUT', `/cards/${cardId}/note/text`, { text });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async appendNoteText(cardId, text) {
|
|
261
|
+
return this.request('POST', `/cards/${cardId}/note/text/append`, { text });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async createNote(cardId) {
|
|
265
|
+
return this.request('POST', `/cards/${cardId}/note`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async deleteNote(cardId) {
|
|
269
|
+
return this.request('DELETE', `/cards/${cardId}/note`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- Canvas/Whiteboard ---
|
|
273
|
+
|
|
274
|
+
async getCanvasSummary(cardId) {
|
|
275
|
+
return this.request('GET', `/cards/${cardId}/whiteboard/summary`);
|
|
276
|
+
}
|
|
222
277
|
}
|
|
223
278
|
|
|
224
279
|
export const api = new KanonAPI();
|
package/src/lib/claude.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import { fileURLToPath } from 'url';
|
|
4
5
|
import chalk from 'chalk';
|
|
6
|
+
import { getServerUrl, getToken } from './config.js';
|
|
5
7
|
|
|
6
8
|
// Resolve the bin/ directory so `kanon` is in PATH for spawned Claude sessions
|
|
7
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -10,6 +12,19 @@ const kanonBinDir = path.resolve(__dirname, '../../bin');
|
|
|
10
12
|
/** Active Claude processes indexed by cardId */
|
|
11
13
|
const activeWorkers = new Map();
|
|
12
14
|
|
|
15
|
+
/** Fire-and-forget POST to server for agent output streaming */
|
|
16
|
+
function _flushToServer(cardId, boardId, lines, config) {
|
|
17
|
+
const serverUrl = getServerUrl();
|
|
18
|
+
const token = getToken();
|
|
19
|
+
if (!serverUrl || !token) return;
|
|
20
|
+
fetch(`${serverUrl}/api/agents/output`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
23
|
+
body: JSON.stringify({ cardId, boardId, lines }),
|
|
24
|
+
}).catch(() => {}); // fire-and-forget
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Spawn a Claude Code process for a card.
|
|
15
30
|
* Returns a WorkerInfo object tracked by the agent controller.
|
|
@@ -35,6 +50,16 @@ export function spawnClaude(cardId, prompt, config = {}) {
|
|
|
35
50
|
|
|
36
51
|
const projectDir = config.project_dir || process.cwd();
|
|
37
52
|
|
|
53
|
+
// Write a temporary kanon.config.yaml so `kanon board/cards/list` work inside the agent
|
|
54
|
+
let tempConfigPath = null;
|
|
55
|
+
if (config.boardId) {
|
|
56
|
+
const configPath = path.join(projectDir, 'kanon.config.yaml');
|
|
57
|
+
if (!fs.existsSync(configPath)) {
|
|
58
|
+
tempConfigPath = configPath;
|
|
59
|
+
fs.writeFileSync(configPath, `board_id: "${config.boardId}"\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
const worker = {
|
|
39
64
|
cardId,
|
|
40
65
|
startedAt: Date.now(),
|
|
@@ -42,6 +67,10 @@ export function spawnClaude(cardId, prompt, config = {}) {
|
|
|
42
67
|
output: [],
|
|
43
68
|
exitCode: null,
|
|
44
69
|
process: null,
|
|
70
|
+
_pendingServerLines: [],
|
|
71
|
+
_boardId: config.boardId || null,
|
|
72
|
+
_outputCardId: config.outputCardId || null,
|
|
73
|
+
_tempConfigPath: tempConfigPath,
|
|
45
74
|
};
|
|
46
75
|
|
|
47
76
|
const proc = spawn(claudeCmd, args, {
|
|
@@ -53,6 +82,14 @@ export function spawnClaude(cardId, prompt, config = {}) {
|
|
|
53
82
|
|
|
54
83
|
worker.process = proc;
|
|
55
84
|
|
|
85
|
+
// Flush pending output to server every 3 seconds + poll for kill requests
|
|
86
|
+
worker._flushTimer = setInterval(() => {
|
|
87
|
+
if (worker._pendingServerLines.length > 0 && worker._boardId) {
|
|
88
|
+
const lines = worker._pendingServerLines.splice(0);
|
|
89
|
+
_flushToServer(worker._outputCardId || worker.cardId, worker._boardId, lines, config);
|
|
90
|
+
}
|
|
91
|
+
}, 3000);
|
|
92
|
+
|
|
56
93
|
// Buffer for incomplete JSON lines from stream-json output
|
|
57
94
|
let stdoutBuf = '';
|
|
58
95
|
|
|
@@ -73,7 +110,8 @@ export function spawnClaude(cardId, prompt, config = {}) {
|
|
|
73
110
|
// Not valid JSON – store raw line as-is
|
|
74
111
|
worker.lastOutput = Date.now();
|
|
75
112
|
worker.output.push(trimmed);
|
|
76
|
-
|
|
113
|
+
worker._pendingServerLines.push(trimmed);
|
|
114
|
+
if (worker.output.length > 500) worker.output.shift();
|
|
77
115
|
continue;
|
|
78
116
|
}
|
|
79
117
|
|
|
@@ -84,21 +122,28 @@ export function spawnClaude(cardId, prompt, config = {}) {
|
|
|
84
122
|
for (const block of parsed.message.content) {
|
|
85
123
|
if (block.type === 'text' && block.text) {
|
|
86
124
|
worker.output.push(block.text);
|
|
125
|
+
worker._pendingServerLines.push(block.text);
|
|
87
126
|
} else if (block.type === 'tool_use') {
|
|
88
127
|
const name = block.name || 'unknown';
|
|
89
128
|
const inputPreview = block.input
|
|
90
129
|
? JSON.stringify(block.input).substring(0, 120)
|
|
91
130
|
: '';
|
|
92
|
-
|
|
131
|
+
const toolLine = `[Tool: ${name}] ${inputPreview}`;
|
|
132
|
+
worker.output.push(toolLine);
|
|
133
|
+
worker._pendingServerLines.push(toolLine);
|
|
93
134
|
}
|
|
94
|
-
if (worker.output.length >
|
|
135
|
+
if (worker.output.length > 500) worker.output.shift();
|
|
95
136
|
}
|
|
96
137
|
} else if (parsed.type === 'result' && parsed.result) {
|
|
97
|
-
|
|
98
|
-
|
|
138
|
+
const resultLine = `[Result] ${parsed.result.substring(0, 200)}`;
|
|
139
|
+
worker.output.push(resultLine);
|
|
140
|
+
worker._pendingServerLines.push(resultLine);
|
|
141
|
+
if (worker.output.length > 500) worker.output.shift();
|
|
99
142
|
} else if (parsed.type === 'error') {
|
|
100
|
-
|
|
101
|
-
|
|
143
|
+
const errorLine = `[Error] ${parsed.error?.message || JSON.stringify(parsed)}`;
|
|
144
|
+
worker.output.push(errorLine);
|
|
145
|
+
worker._pendingServerLines.push(errorLine);
|
|
146
|
+
if (worker.output.length > 500) worker.output.shift();
|
|
102
147
|
}
|
|
103
148
|
// Silently skip other message types (system, etc.)
|
|
104
149
|
}
|
|
@@ -107,12 +152,23 @@ export function spawnClaude(cardId, prompt, config = {}) {
|
|
|
107
152
|
proc.stderr.on('data', (data) => {
|
|
108
153
|
const line = data.toString();
|
|
109
154
|
worker.lastOutput = Date.now();
|
|
110
|
-
|
|
111
|
-
|
|
155
|
+
const stderrLine = `[stderr] ${line}`;
|
|
156
|
+
worker.output.push(stderrLine);
|
|
157
|
+
worker._pendingServerLines.push(stderrLine);
|
|
158
|
+
if (worker.output.length > 500) worker.output.shift();
|
|
112
159
|
});
|
|
113
160
|
|
|
114
161
|
proc.on('close', (code) => {
|
|
115
162
|
worker.exitCode = code;
|
|
163
|
+
clearInterval(worker._flushTimer);
|
|
164
|
+
// Final flush
|
|
165
|
+
if (worker._pendingServerLines.length > 0 && worker._boardId) {
|
|
166
|
+
_flushToServer(worker._outputCardId || worker.cardId, worker._boardId, worker._pendingServerLines.splice(0), config);
|
|
167
|
+
}
|
|
168
|
+
// Clean up temp config
|
|
169
|
+
if (worker._tempConfigPath) {
|
|
170
|
+
try { fs.unlinkSync(worker._tempConfigPath); } catch {}
|
|
171
|
+
}
|
|
116
172
|
activeWorkers.delete(cardId);
|
|
117
173
|
console.log(chalk.dim(`Worker for card ${cardId} exited with code ${code}`));
|
|
118
174
|
});
|