pi-kanban 0.2.0 → 0.3.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.
package/public/style.css CHANGED
@@ -20,6 +20,7 @@
20
20
  --accent-glow: rgba(232, 111, 51, 0.55);
21
21
  --success: #3ecf8e;
22
22
  --success-dim: rgba(62, 207, 142, 0.18);
23
+ --error: #e5534b;
23
24
  --warning: #f0b429;
24
25
  --warning-dim: rgba(240, 180, 41, 0.18);
25
26
  --plan: #86a886;
@@ -761,6 +762,13 @@ body::before {
761
762
  display: flex;
762
763
  align-items: center;
763
764
  justify-content: space-between;
765
+ flex-wrap: wrap;
766
+ gap: 12px;
767
+ }
768
+
769
+ .view-header > div:first-child {
770
+ min-width: 0;
771
+ flex: 1 1 auto;
764
772
  }
765
773
 
766
774
  .view-title {
@@ -789,6 +797,12 @@ body::before {
789
797
  display: flex;
790
798
  align-items: center;
791
799
  gap: 16px;
800
+ flex-wrap: wrap;
801
+ }
802
+
803
+ @media (max-width: 720px) {
804
+ .view-progress { display: none; }
805
+ .view-actions { gap: 8px; }
792
806
  }
793
807
 
794
808
  .view-progress {
@@ -2534,6 +2548,7 @@ body.light {
2534
2548
  --accent-glow: rgba(232, 111, 51, 0.5);
2535
2549
  --success: #1a8a5a;
2536
2550
  --success-dim: rgba(26, 138, 90, 0.15);
2551
+ --error: #c0392b;
2537
2552
  --warning: #b07d0a;
2538
2553
  --warning-dim: rgba(176, 125, 10, 0.15);
2539
2554
  --plan: #5a7a5a;
@@ -3234,6 +3249,7 @@ select.form-input option:checked {
3234
3249
  --accent-glow: rgba(232, 111, 51, 0.5);
3235
3250
  --success: #1a8a5a;
3236
3251
  --success-dim: rgba(26, 138, 90, 0.15);
3252
+ --error: #c0392b;
3237
3253
  --warning: #b07d0a;
3238
3254
  --warning-dim: rgba(176, 125, 10, 0.15);
3239
3255
  --plan: #5a7a5a;
@@ -3614,3 +3630,131 @@ pre.mermaid svg {
3614
3630
  border: 1px solid var(--text-muted);
3615
3631
  }
3616
3632
  /* #endregion */
3633
+
3634
+ /* #region AskUserQuestion card */
3635
+ .ask-user-questions {
3636
+ display: flex;
3637
+ flex-direction: column;
3638
+ gap: 10px;
3639
+ margin-bottom: 12px;
3640
+ }
3641
+ .ask-user-card {
3642
+ border: 1px solid var(--border);
3643
+ border-radius: 6px;
3644
+ padding: 10px 12px;
3645
+ background: var(--bg-secondary, var(--bg-elevated, rgba(127,127,127,0.06)));
3646
+ }
3647
+ .ask-user-header {
3648
+ font-size: 0.72rem;
3649
+ font-weight: 600;
3650
+ text-transform: uppercase;
3651
+ letter-spacing: 0.05em;
3652
+ color: var(--accent);
3653
+ margin-bottom: 4px;
3654
+ }
3655
+ .ask-user-question-text {
3656
+ font-size: 0.9rem;
3657
+ font-weight: 500;
3658
+ color: var(--text-primary);
3659
+ margin-bottom: 6px;
3660
+ }
3661
+ .ask-user-hint {
3662
+ font-size: 0.72rem;
3663
+ color: var(--text-muted);
3664
+ margin-bottom: 6px;
3665
+ }
3666
+ .ask-user-options {
3667
+ list-style: none;
3668
+ margin: 0;
3669
+ padding: 0;
3670
+ display: flex;
3671
+ flex-direction: column;
3672
+ gap: 3px;
3673
+ }
3674
+ .ask-user-option {
3675
+ display: flex;
3676
+ align-items: flex-start;
3677
+ gap: 6px;
3678
+ font-size: 0.85rem;
3679
+ color: var(--text-secondary);
3680
+ padding: 3px 6px;
3681
+ border-radius: 4px;
3682
+ }
3683
+ .ask-user-option--checked {
3684
+ color: var(--text-primary);
3685
+ background: var(--bg-hover);
3686
+ }
3687
+ .ask-user-check {
3688
+ flex-shrink: 0;
3689
+ width: 14px;
3690
+ color: var(--success);
3691
+ font-weight: 700;
3692
+ }
3693
+ .ask-user-option:not(.ask-user-option--checked) .ask-user-check {
3694
+ color: var(--text-muted);
3695
+ }
3696
+ .ask-user-option-label {
3697
+ font-weight: 500;
3698
+ }
3699
+ .ask-user-option-desc {
3700
+ color: var(--text-muted);
3701
+ font-size: 0.78rem;
3702
+ margin-left: 4px;
3703
+ }
3704
+ .ask-user-pending {
3705
+ color: var(--text-muted);
3706
+ font-size: 0.85rem;
3707
+ font-style: italic;
3708
+ margin-top: 6px;
3709
+ }
3710
+ .ask-user-cancelled {
3711
+ color: var(--text-muted);
3712
+ font-size: 0.85rem;
3713
+ font-style: italic;
3714
+ padding: 8px 0;
3715
+ }
3716
+ /* #endregion */
3717
+
3718
+ /* #region parallel-subagent */
3719
+ .psa-mode-label {
3720
+ font-size: 11px;
3721
+ color: var(--text-secondary);
3722
+ text-transform: uppercase;
3723
+ letter-spacing: 0.05em;
3724
+ margin-bottom: 6px;
3725
+ }
3726
+ .psa-row {
3727
+ display: flex;
3728
+ align-items: baseline;
3729
+ gap: 8px;
3730
+ margin: 6px 0;
3731
+ }
3732
+ .psa-task-row {
3733
+ align-items: flex-start;
3734
+ }
3735
+ .psa-key {
3736
+ font-size: 11px;
3737
+ color: var(--text-secondary);
3738
+ flex-shrink: 0;
3739
+ min-width: 40px;
3740
+ }
3741
+ .psa-task-text {
3742
+ white-space: pre-wrap;
3743
+ word-break: break-word;
3744
+ font-size: 12px;
3745
+ color: var(--text-primary);
3746
+ line-height: 1.5;
3747
+ }
3748
+ .psa-status {
3749
+ margin-top: 8px;
3750
+ font-size: 12px;
3751
+ font-weight: 500;
3752
+ }
3753
+ .psa-status-ok {
3754
+ color: var(--success);
3755
+ }
3756
+ .psa-status-err {
3757
+ color: var(--error);
3758
+ }
3759
+ /* #endregion */
3760
+
package/server.js CHANGED
@@ -10,6 +10,16 @@ const chokidar = require('chokidar');
10
10
  const open = require('open').default || require('open');
11
11
  const parsers = require('./lib/pi-parsers');
12
12
  const taskStore = require('./lib/task-store');
13
+ const pkg = require('./package.json');
14
+
15
+ function sendWithETag(req, res, data) {
16
+ const body = typeof data === 'string' ? data : JSON.stringify(data);
17
+ const etag = `"${crypto.createHash('md5').update(body).digest('hex').slice(0, 16)}"`;
18
+ res.set('ETag', etag);
19
+ res.set('Cache-Control', 'no-cache');
20
+ if (req.headers['if-none-match'] === etag) return res.status(304).end();
21
+ res.type('application/json').send(body);
22
+ }
13
23
 
14
24
  function enrichTask(t, sessionId, project) {
15
25
  return {
@@ -23,23 +33,27 @@ function enrichTask(t, sessionId, project) {
23
33
  }
24
34
 
25
35
  async function tasksForSession(sessionId, project) {
26
- const tasks = taskStore.listTasks(sessionId);
36
+ const tasks = await taskStore.listTasksAsync(sessionId);
27
37
  return tasks.map((t) => enrichTask(t, sessionId, project));
28
38
  }
29
39
 
30
40
  async function allStoredTasks() {
31
- const out = [];
32
- for (const sid of taskStore.listSessionIds()) {
33
- const tasks = taskStore.listTasks(sid);
34
- if (!tasks.length) continue;
35
- let project = null;
36
- try {
37
- const meta = await parsers.findSessionFileById(sid);
38
- if (meta) project = meta.cwd;
39
- } catch {}
40
- for (const t of tasks) out.push(enrichTask(t, sid, project));
41
+ const [sids, sessionFiles] = await Promise.all([
42
+ taskStore.listSessionIdsAsync(),
43
+ parsers.listSessionFiles(),
44
+ ]);
45
+ const projectBySid = new Map();
46
+ for (const meta of sessionFiles) {
47
+ const slug = parsers.slugFromFile(meta.file);
48
+ if (!projectBySid.has(slug)) projectBySid.set(slug, meta.cwd);
41
49
  }
42
- return out;
50
+ const groups = await Promise.all(sids.map(async (sid) => {
51
+ const tasks = await taskStore.listTasksAsync(sid);
52
+ if (!tasks.length) return [];
53
+ const project = projectBySid.get(sid) || null;
54
+ return tasks.map((t) => enrichTask(t, sid, project));
55
+ }));
56
+ return groups.flat();
43
57
  }
44
58
 
45
59
 
@@ -112,7 +126,7 @@ app.use(express.static(path.join(__dirname, 'public')));
112
126
  const empty = (_req, res) => res.json([]);
113
127
  const emptyObj = (_req, res) => res.json({});
114
128
 
115
- app.get('/api/version', (_req, res) => res.json({ version: '0.1.0', name: 'pi-kanban' }));
129
+ app.get('/api/version', (_req, res) => res.json({ version: pkg.version, name: pkg.name }));
116
130
 
117
131
  function resolveActiveTheme(mode) {
118
132
  const requestedId = mode === 'light' ? LIGHT_THEME_ID : DARK_THEME_ID;
@@ -137,7 +151,7 @@ app.get('/api/sessions', async (req, res) => {
137
151
  try {
138
152
  const limit = req.query.limit ? Number(req.query.limit) : 50;
139
153
  const all = await parsers.listSessions();
140
- res.json(all.slice(0, limit));
154
+ sendWithETag(req, res, all.slice(0, limit));
141
155
  } catch (err) {
142
156
  console.error('listSessions error', err);
143
157
  res.status(500).json({ error: String(err) });
@@ -158,7 +172,7 @@ app.get('/api/sessions/:id/summary', async (req, res) => {
158
172
  const meta = await parsers.findSessionFileById(req.params.id);
159
173
  if (!meta) return res.status(404).json({ error: 'not found' });
160
174
  try {
161
- res.json(await parsers.buildSessionSummary(meta));
175
+ sendWithETag(req, res, await parsers.buildSessionSummary(meta));
162
176
  } catch (err) {
163
177
  res.status(500).json({ error: String(err) });
164
178
  }
@@ -172,18 +186,44 @@ app.get('/api/sessions/:id/messages', async (req, res) => {
172
186
  const before = req.query.before ? String(req.query.before) : null;
173
187
  const out = await parsers.buildMessages(meta.file, limit, before);
174
188
  out.sessionId = req.params.id;
175
- res.json(out);
189
+ sendWithETag(req, res, out);
176
190
  } catch (err) {
177
191
  res.status(500).json({ messages: [], error: String(err) });
178
192
  }
179
193
  });
180
194
 
195
+ app.get('/api/sessions/:id/tool-result/:toolUseId', async (req, res) => {
196
+ const meta = await parsers.findSessionFileById(req.params.id);
197
+ if (!meta) return res.status(404).json({ error: 'not found' });
198
+ try {
199
+ const entries = await parsers.readSessionEntries(meta.file);
200
+ const toolUseId = req.params.toolUseId;
201
+ for (const e of entries) {
202
+ if (e.type !== 'message') continue;
203
+ const m = e.message;
204
+ if (m && m.role === 'toolResult' && m.toolCallId === toolUseId) {
205
+ const content = parsers.flattenContentToText(m.content);
206
+ return res.json({ content });
207
+ }
208
+ }
209
+ res.status(404).json({ error: 'tool result not found' });
210
+ } catch (err) {
211
+ res.status(500).json({ error: String(err) });
212
+ }
213
+ });
214
+
215
+
181
216
  app.get('/api/sessions/:id/agents', async (req, res) => {
182
217
  const meta = await parsers.findSessionFileById(req.params.id);
183
218
  if (!meta) return res.status(404).json({ agents: [], error: 'not found' });
184
219
  try {
185
- const agents = await parsers.listAgentsForSession(meta);
186
- res.json({ agents, waitingForUser: null });
220
+ const entries = await parsers.readSessionEntries(meta.file);
221
+ const s = parsers.summarize(entries);
222
+ const agents = await parsers.listAgentsForSession(meta, s);
223
+ const waitingForUser = s.pendingAskUser
224
+ ? { kind: 'question', toolName: 'AskUserQuestion', timestamp: s.pendingAskUser.timestamp }
225
+ : null;
226
+ res.json({ agents, waitingForUser });
187
227
  } catch (err) {
188
228
  res.status(500).json({ agents: [], error: String(err) });
189
229
  }
@@ -237,8 +277,8 @@ app.post('/api/session/plan', async (req, res) => {
237
277
  });
238
278
 
239
279
  // Stubbed endpoints (later phases may fill these).
240
- app.get('/api/tasks/all', async (_req, res) => {
241
- try { res.json(await allStoredTasks()); }
280
+ app.get('/api/tasks/all', async (req, res) => {
281
+ try { sendWithETag(req, res, await allStoredTasks()); }
242
282
  catch (err) { res.status(500).json({ error: String(err) }); }
243
283
  });
244
284
  app.get('/api/projects', async (_req, res) => {
@@ -410,6 +450,49 @@ app.get('/api/events', (req, res) => {
410
450
  req.on('close', () => { clearInterval(ka); sseClients.delete(res); });
411
451
  });
412
452
 
453
+ // First call for a key arms a timer; further calls inside the window are
454
+ // dropped (leading-edge debounce). Used for SSE coalescing.
455
+ function debounceByKey(ms, fn) {
456
+ const pending = new Map();
457
+ return (key) => {
458
+ if (pending.has(key)) return;
459
+ pending.set(key, setTimeout(() => {
460
+ pending.delete(key);
461
+ fn(key);
462
+ }, ms));
463
+ };
464
+ }
465
+
466
+ // Per-file subscriber set. Notified by the global watcher; parses once per
467
+ // change and fans out to all connected clients (replaces per-client chokidar).
468
+ const agentLogStreams = (() => {
469
+ const byFile = new Map();
470
+ async function broadcast(file) {
471
+ const subs = byFile.get(file);
472
+ if (!subs || !subs.size) return;
473
+ let payload;
474
+ try { payload = await parsers.buildMessages(file, 50); } catch { return; }
475
+ for (const { res, agentId } of subs) {
476
+ payload.agentId = agentId;
477
+ try { res.write(`event: agent-log-update\ndata: ${JSON.stringify(payload)}\n\n`); } catch {}
478
+ }
479
+ }
480
+ const flush = debounceByKey(75, broadcast);
481
+ return {
482
+ subscribe(file, res, agentId) {
483
+ let set = byFile.get(file);
484
+ if (!set) { set = new Set(); byFile.set(file, set); }
485
+ const sub = { res, agentId };
486
+ set.add(sub);
487
+ return () => {
488
+ set.delete(sub);
489
+ if (!set.size) byFile.delete(file);
490
+ };
491
+ },
492
+ notify(file) { if (byFile.has(file)) flush(file); },
493
+ };
494
+ })();
495
+
413
496
  app.get('/api/sessions/:id/agents/:agentId/messages/stream', async (req, res) => {
414
497
  res.set({
415
498
  'Content-Type': 'text/event-stream',
@@ -418,11 +501,11 @@ app.get('/api/sessions/:id/agents/:agentId/messages/stream', async (req, res) =>
418
501
  });
419
502
  res.write(': connected\n\n');
420
503
 
421
- let watcherRef = null;
504
+ let unsubscribe = null;
422
505
  const ka = setInterval(() => { try { res.write(': ka\n\n'); } catch {} }, 30000);
423
506
  req.on('close', () => {
424
507
  clearInterval(ka);
425
- if (watcherRef) { try { watcherRef.close(); } catch {} }
508
+ if (unsubscribe) unsubscribe();
426
509
  });
427
510
 
428
511
  let meta;
@@ -430,19 +513,16 @@ app.get('/api/sessions/:id/agents/:agentId/messages/stream', async (req, res) =>
430
513
  const rec = meta ? await parsers.findAgentRecord(meta, req.params.agentId).catch(() => null) : null;
431
514
  if (!rec || !rec._sessionFile) return;
432
515
 
433
- const send = async () => {
434
- try {
435
- const out = await parsers.buildMessages(rec._sessionFile, 50);
436
- out.agentId = req.params.agentId;
437
- res.write(`event: agent-log-update\ndata: ${JSON.stringify(out)}\n\n`);
438
- } catch {}
439
- };
440
- await send();
441
- watcherRef = chokidar.watch(rec._sessionFile, {
442
- ignoreInitial: true,
443
- awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
444
- });
445
- watcherRef.on('change', send).on('add', send);
516
+ // Subscribe before sending initial payload: any change during buildMessages
517
+ // will fire notify -> broadcast and be picked up. Otherwise the gap between
518
+ // initial send and subscribe could drop events.
519
+ unsubscribe = agentLogStreams.subscribe(rec._sessionFile, res, req.params.agentId);
520
+
521
+ try {
522
+ const out = await parsers.buildMessages(rec._sessionFile, 50);
523
+ out.agentId = req.params.agentId;
524
+ res.write(`event: agent-log-update\ndata: ${JSON.stringify(out)}\n\n`);
525
+ } catch {}
446
526
  });
447
527
 
448
528
  const watcher = chokidar.watch(path.join(parsers.getSessionsDir(), '**/*.jsonl'), {
@@ -453,9 +533,15 @@ parsers.setOnBranchResolved((cwd, branch) => {
453
533
  sseSend({ type: 'branch:resolved', cwd, branch });
454
534
  });
455
535
 
456
- watcher.on('add', (f) => sseSend({ type: 'update', sessionId: parsers.slugFromFile(f) }));
457
- watcher.on('change', (f) => sseSend({ type: 'update', sessionId: parsers.slugFromFile(f) }));
458
- watcher.on('unlink', (f) => sseSend({ type: 'update', sessionId: parsers.slugFromFile(f) }));
536
+ const queueSessionUpdate = debounceByKey(75, (sid) => sseSend({ type: 'update', sessionId: sid }));
537
+
538
+ for (const ev of ['add', 'change', 'unlink']) {
539
+ watcher.on(ev, (f) => {
540
+ parsers.invalidateSessionCache(f);
541
+ agentLogStreams.notify(f);
542
+ queueSessionUpdate(parsers.slugFromFile(f));
543
+ });
544
+ }
459
545
 
460
546
  const taskWatcher = chokidar.watch(taskStore.getTasksDir(), {
461
547
  ignoreInitial: true,
@@ -463,14 +549,7 @@ const taskWatcher = chokidar.watch(taskStore.getTasksDir(), {
463
549
  awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
464
550
  });
465
551
  const tasksRoot = path.basename(taskStore.getTasksDir());
466
- const pendingTaskSse = new Map();
467
- function queueTaskUpdate(sid) {
468
- if (pendingTaskSse.has(sid)) return;
469
- pendingTaskSse.set(sid, setTimeout(() => {
470
- pendingTaskSse.delete(sid);
471
- sseSend({ type: 'task:update', sessionId: sid });
472
- }, 75));
473
- }
552
+ const queueTaskUpdate = debounceByKey(75, (sid) => sseSend({ type: 'task:update', sessionId: sid }));
474
553
  taskWatcher.on('all', (_evt, file) => {
475
554
  if (!file || !file.endsWith('.json')) return;
476
555
  const sid = path.basename(path.dirname(file));