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.
@@ -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-Dcbpx-Xz.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-DhFfv70f.css">
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
- if (config.token) headers['Authorization'] = `Bearer ${config.token}`;
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 (masked password)
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
- res.json({
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 update = { ...current };
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 (req.body.server_url) update.server_url = req.body.server_url;
195
- if (req.body.email) update.email = req.body.email;
196
- if (req.body.password && req.body.password !== '********') {
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
- saveGlobalConfig(update);
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
- if (worker.output.length > 100) worker.output.shift();
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
- worker.output.push(`[Tool: ${name}] ${inputPreview}`);
131
+ const toolLine = `[Tool: ${name}] ${inputPreview}`;
132
+ worker.output.push(toolLine);
133
+ worker._pendingServerLines.push(toolLine);
93
134
  }
94
- if (worker.output.length > 100) worker.output.shift();
135
+ if (worker.output.length > 500) worker.output.shift();
95
136
  }
96
137
  } else if (parsed.type === 'result' && parsed.result) {
97
- worker.output.push(`[Result] ${parsed.result.substring(0, 200)}`);
98
- if (worker.output.length > 100) worker.output.shift();
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
- worker.output.push(`[Error] ${parsed.error?.message || JSON.stringify(parsed)}`);
101
- if (worker.output.length > 100) worker.output.shift();
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
- worker.output.push(`[stderr] ${line}`);
111
- if (worker.output.length > 100) worker.output.shift();
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
  });