pi-kanban 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.
- package/README.md +44 -0
- package/extensions/kanban.ts +382 -0
- package/lib/git-branch.js +112 -0
- package/lib/pi-parsers.js +889 -0
- package/lib/task-store.js +134 -0
- package/package.json +57 -0
- package/public/app.js +5690 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +6 -0
- package/public/index.html +674 -0
- package/public/manifest.json +26 -0
- package/public/style.css +3608 -0
- package/public/sw.js +60 -0
- package/server.js +448 -0
- package/themes/kanban-dark-default.json +21 -0
- package/themes/kanban-default.json +21 -0
- package/themes/pi-dark.json +21 -0
- package/themes/pi-light.json +21 -0
package/public/sw.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const CACHE_NAME = 'pi-kanban-v2';
|
|
2
|
+
const PRECACHE_URLS = ['/', '/style.css', '/app.js'];
|
|
3
|
+
|
|
4
|
+
let cachePromise = null;
|
|
5
|
+
function getCache() {
|
|
6
|
+
if (!cachePromise) cachePromise = caches.open(CACHE_NAME);
|
|
7
|
+
return cachePromise;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function cacheResponse(request, response) {
|
|
11
|
+
if (response.ok) {
|
|
12
|
+
const clone = response.clone();
|
|
13
|
+
getCache().then(cache => cache.put(request, clone));
|
|
14
|
+
}
|
|
15
|
+
return response;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
self.addEventListener('install', (event) => {
|
|
19
|
+
event.waitUntil(
|
|
20
|
+
getCache()
|
|
21
|
+
.then(cache => cache.addAll(PRECACHE_URLS))
|
|
22
|
+
.then(() => self.skipWaiting())
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
self.addEventListener('activate', (event) => {
|
|
27
|
+
cachePromise = null;
|
|
28
|
+
event.waitUntil(
|
|
29
|
+
caches.keys()
|
|
30
|
+
.then(keys => Promise.all(
|
|
31
|
+
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
|
|
32
|
+
))
|
|
33
|
+
.then(() => self.clients.claim())
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
self.addEventListener('fetch', (event) => {
|
|
38
|
+
const url = new URL(event.request.url);
|
|
39
|
+
|
|
40
|
+
// Never cache API calls or SSE streams
|
|
41
|
+
if (url.pathname.startsWith('/api')) return;
|
|
42
|
+
|
|
43
|
+
// Network-first for same-origin, fallback to cache
|
|
44
|
+
if (url.origin === self.location.origin) {
|
|
45
|
+
event.respondWith(
|
|
46
|
+
fetch(event.request)
|
|
47
|
+
.then(r => cacheResponse(event.request, r))
|
|
48
|
+
.catch(() => caches.match(event.request))
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// CDN resources: cache-first
|
|
54
|
+
event.respondWith(
|
|
55
|
+
caches.match(event.request).then(cached => {
|
|
56
|
+
if (cached) return cached;
|
|
57
|
+
return fetch(event.request).then(r => cacheResponse(event.request, r));
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
});
|
package/server.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const fsp = require('node:fs/promises');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
const { spawn } = require('node:child_process');
|
|
9
|
+
const chokidar = require('chokidar');
|
|
10
|
+
const open = require('open').default || require('open');
|
|
11
|
+
const parsers = require('./lib/pi-parsers');
|
|
12
|
+
const taskStore = require('./lib/task-store');
|
|
13
|
+
|
|
14
|
+
function enrichTask(t, sessionId, project) {
|
|
15
|
+
return {
|
|
16
|
+
id: String(t.id),
|
|
17
|
+
subject: t.subject || '',
|
|
18
|
+
status: t.status || 'pending',
|
|
19
|
+
description: t.description ?? null,
|
|
20
|
+
sessionId,
|
|
21
|
+
project,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function tasksForSession(sessionId, project) {
|
|
26
|
+
const tasks = taskStore.listTasks(sessionId);
|
|
27
|
+
return tasks.map((t) => enrichTask(t, sessionId, project));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
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
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
const PORT = Number(process.env.PORT) || 3460;
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
const shouldOpen = args.includes('--open');
|
|
49
|
+
|
|
50
|
+
const BUILTIN_THEME_DIR = path.join(__dirname, 'themes');
|
|
51
|
+
const USER_THEME_DIR = process.env.KANBAN_THEME_DIR
|
|
52
|
+
|| path.join(os.homedir(), '.pi', 'agent', 'kanban', 'themes');
|
|
53
|
+
const LIGHT_THEME_ID = process.env.KANBAN_LIGHT_THEME || 'pi-light';
|
|
54
|
+
const DARK_THEME_ID = process.env.KANBAN_DARK_THEME || 'pi-dark';
|
|
55
|
+
|
|
56
|
+
const REQUIRED_COLOR_KEYS = [
|
|
57
|
+
'bgDeep', 'bgSurface', 'bgElevated', 'bgHover',
|
|
58
|
+
'border',
|
|
59
|
+
'textPrimary', 'textSecondary', 'textTertiary', 'textMuted',
|
|
60
|
+
'accent', 'accentText',
|
|
61
|
+
'success', 'warning', 'plan',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const themes = new Map();
|
|
65
|
+
|
|
66
|
+
async function loadThemesFromDir(dir, builtin) {
|
|
67
|
+
let entries;
|
|
68
|
+
try { entries = await fsp.readdir(dir); }
|
|
69
|
+
catch (e) {
|
|
70
|
+
if (e.code !== 'ENOENT') console.warn(`themes: cannot read ${dir}: ${e.message}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const jsons = entries.filter((f) => f.toLowerCase().endsWith('.json'));
|
|
74
|
+
await Promise.all(jsons.map(async (f) => {
|
|
75
|
+
const full = path.join(dir, f);
|
|
76
|
+
const id = f.replace(/\.json$/i, '');
|
|
77
|
+
try {
|
|
78
|
+
const raw = await fsp.readFile(full, 'utf8');
|
|
79
|
+
const obj = JSON.parse(raw);
|
|
80
|
+
if (obj.mode !== 'light' && obj.mode !== 'dark') {
|
|
81
|
+
console.warn(`themes: skip ${full}: invalid mode`); return;
|
|
82
|
+
}
|
|
83
|
+
const colors = obj.colors || {};
|
|
84
|
+
const missing = REQUIRED_COLOR_KEYS.filter((k) => typeof colors[k] !== 'string');
|
|
85
|
+
if (missing.length) {
|
|
86
|
+
console.warn(`themes: skip ${full}: missing colors ${missing.join(',')}`); return;
|
|
87
|
+
}
|
|
88
|
+
themes.set(id, {
|
|
89
|
+
id,
|
|
90
|
+
name: obj.name || id,
|
|
91
|
+
displayName: obj.displayName || obj.name || id,
|
|
92
|
+
mode: obj.mode,
|
|
93
|
+
colors,
|
|
94
|
+
builtin,
|
|
95
|
+
});
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.warn(`themes: skip ${full}: ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function loadAllThemes() {
|
|
103
|
+
themes.clear();
|
|
104
|
+
await loadThemesFromDir(BUILTIN_THEME_DIR, true);
|
|
105
|
+
await loadThemesFromDir(USER_THEME_DIR, false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const app = express();
|
|
109
|
+
app.use(express.json());
|
|
110
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
111
|
+
|
|
112
|
+
const empty = (_req, res) => res.json([]);
|
|
113
|
+
const emptyObj = (_req, res) => res.json({});
|
|
114
|
+
|
|
115
|
+
app.get('/api/version', (_req, res) => res.json({ version: '0.1.0', name: 'pi-kanban' }));
|
|
116
|
+
|
|
117
|
+
function resolveActiveTheme(mode) {
|
|
118
|
+
const requestedId = mode === 'light' ? LIGHT_THEME_ID : DARK_THEME_ID;
|
|
119
|
+
const fallbackId = mode === 'light' ? 'pi-light' : 'pi-dark';
|
|
120
|
+
let t = themes.get(requestedId);
|
|
121
|
+
if (t && t.mode !== mode) {
|
|
122
|
+
console.warn(`themes: ${requestedId} mode=${t.mode}, expected ${mode}; using fallback`);
|
|
123
|
+
t = null;
|
|
124
|
+
}
|
|
125
|
+
return t || themes.get(fallbackId) || null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
app.get('/api/themes', (_req, res) => {
|
|
129
|
+
res.json({
|
|
130
|
+
light: resolveActiveTheme('light'),
|
|
131
|
+
dark: resolveActiveTheme('dark'),
|
|
132
|
+
config: { lightId: LIGHT_THEME_ID, darkId: DARK_THEME_ID, themeDir: USER_THEME_DIR },
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
app.get('/api/sessions', async (req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
const limit = req.query.limit ? Number(req.query.limit) : 50;
|
|
139
|
+
const all = await parsers.listSessions();
|
|
140
|
+
res.json(all.slice(0, limit));
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error('listSessions error', err);
|
|
143
|
+
res.status(500).json({ error: String(err) });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
app.get('/api/sessions/:id', async (req, res) => {
|
|
148
|
+
const meta = await parsers.findSessionFileById(req.params.id);
|
|
149
|
+
if (!meta) return res.status(404).json({ error: 'not found' });
|
|
150
|
+
try {
|
|
151
|
+
res.json(await tasksForSession(req.params.id, meta.cwd));
|
|
152
|
+
} catch (err) {
|
|
153
|
+
res.status(500).json({ error: String(err) });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
app.get('/api/sessions/:id/summary', async (req, res) => {
|
|
158
|
+
const meta = await parsers.findSessionFileById(req.params.id);
|
|
159
|
+
if (!meta) return res.status(404).json({ error: 'not found' });
|
|
160
|
+
try {
|
|
161
|
+
res.json(await parsers.buildSessionSummary(meta));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
res.status(500).json({ error: String(err) });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
app.get('/api/sessions/:id/messages', async (req, res) => {
|
|
168
|
+
const meta = await parsers.findSessionFileById(req.params.id);
|
|
169
|
+
if (!meta) return res.status(404).json({ messages: [], error: 'not found' });
|
|
170
|
+
try {
|
|
171
|
+
const limit = req.query.limit ? Number(req.query.limit) : 50;
|
|
172
|
+
const before = req.query.before ? String(req.query.before) : null;
|
|
173
|
+
const out = await parsers.buildMessages(meta.file, limit, before);
|
|
174
|
+
out.sessionId = req.params.id;
|
|
175
|
+
res.json(out);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
res.status(500).json({ messages: [], error: String(err) });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
app.get('/api/sessions/:id/agents', async (req, res) => {
|
|
182
|
+
const meta = await parsers.findSessionFileById(req.params.id);
|
|
183
|
+
if (!meta) return res.status(404).json({ agents: [], error: 'not found' });
|
|
184
|
+
try {
|
|
185
|
+
const agents = await parsers.listAgentsForSession(meta);
|
|
186
|
+
res.json({ agents, waitingForUser: null });
|
|
187
|
+
} catch (err) {
|
|
188
|
+
res.status(500).json({ agents: [], error: String(err) });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
app.get('/api/sessions/:id/agents/:agentId/messages', async (req, res) => {
|
|
193
|
+
const meta = await parsers.findSessionFileById(req.params.id);
|
|
194
|
+
if (!meta) return res.status(404).json({ messages: [], error: 'not found' });
|
|
195
|
+
try {
|
|
196
|
+
const rec = await parsers.findAgentRecord(meta, req.params.agentId);
|
|
197
|
+
if (!rec || !rec._sessionFile) {
|
|
198
|
+
return res.json({ messages: [], agentId: req.params.agentId });
|
|
199
|
+
}
|
|
200
|
+
const limit = req.query.limit ? Number(req.query.limit) : 50;
|
|
201
|
+
const out = await parsers.buildMessages(rec._sessionFile, limit);
|
|
202
|
+
out.agentId = req.params.agentId;
|
|
203
|
+
res.json(out);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
res.status(500).json({ messages: [], error: String(err) });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Stubbed endpoints (later phases may fill these).
|
|
210
|
+
app.get('/api/sessions/:id/plan', emptyObj);
|
|
211
|
+
app.get('/api/tasks/all', async (_req, res) => {
|
|
212
|
+
try { res.json(await allStoredTasks()); }
|
|
213
|
+
catch (err) { res.status(500).json({ error: String(err) }); }
|
|
214
|
+
});
|
|
215
|
+
app.get('/api/projects', async (_req, res) => {
|
|
216
|
+
try {
|
|
217
|
+
const sessions = await parsers.listSessions();
|
|
218
|
+
const byCwd = new Map();
|
|
219
|
+
for (const s of sessions) {
|
|
220
|
+
const mt = new Date(s.modifiedAt);
|
|
221
|
+
const cur = byCwd.get(s.cwd);
|
|
222
|
+
if (!cur || mt > cur) byCwd.set(s.cwd, mt);
|
|
223
|
+
}
|
|
224
|
+
const out = [...byCwd.entries()].map(([p, mt]) => ({ path: p, modifiedAt: mt.toISOString() }));
|
|
225
|
+
out.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1));
|
|
226
|
+
res.json(out);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
res.status(500).json({ error: String(err) });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
app.get('/api/projects/:name/tasks', async (req, res) => {
|
|
232
|
+
try {
|
|
233
|
+
const all = await allStoredTasks();
|
|
234
|
+
let decoded;
|
|
235
|
+
try { decoded = Buffer.from(req.params.name, 'base64').toString('utf8'); }
|
|
236
|
+
catch { decoded = parsers.decodeProjectDir(req.params.name); }
|
|
237
|
+
res.json(all.filter((t) => t.project === decoded));
|
|
238
|
+
} catch (err) { res.status(500).json({ error: String(err) }); }
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
app.delete('/api/sessions/:id/tasks/:taskId', (req, res) => {
|
|
242
|
+
const { id, taskId } = req.params;
|
|
243
|
+
const existing = taskStore.readTask(id, taskId);
|
|
244
|
+
if (!existing) return res.status(404).json({ error: 'task not found' });
|
|
245
|
+
const all = taskStore.listTasks(id);
|
|
246
|
+
const blockedTasks = all
|
|
247
|
+
.filter((t) => Array.isArray(t.blockedBy) && t.blockedBy.map(String).includes(String(taskId)))
|
|
248
|
+
.map((t) => t.id);
|
|
249
|
+
if (blockedTasks.length) {
|
|
250
|
+
return res.status(400).json({ error: 'task blocks others', blockedTasks });
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
taskStore.deleteTask(id, taskId);
|
|
254
|
+
sseSend({ type: 'task:update', sessionId: id });
|
|
255
|
+
res.json({ success: true });
|
|
256
|
+
} catch (err) { res.status(500).json({ error: String(err) }); }
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
app.get('/api/context-status', emptyObj);
|
|
260
|
+
|
|
261
|
+
async function readMarkdownFile(absPath) {
|
|
262
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
263
|
+
if (ext !== '.md' && ext !== '.markdown') {
|
|
264
|
+
const e = new Error('Only .md/.markdown files are allowed'); e.status = 400; throw e;
|
|
265
|
+
}
|
|
266
|
+
try { return await fsp.readFile(absPath, 'utf8'); }
|
|
267
|
+
catch (e) {
|
|
268
|
+
if (e.code === 'ENOENT') { const err = new Error('File not found'); err.status = 404; throw err; }
|
|
269
|
+
if (e.code === 'EISDIR') { const err = new Error('Not a file'); err.status = 400; throw err; }
|
|
270
|
+
throw e;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function resolvePreviewPath(filePath, base) {
|
|
275
|
+
if (!filePath || typeof filePath !== 'string') return null;
|
|
276
|
+
if (filePath === '~' || filePath.startsWith('~/') || filePath.startsWith('~\\')) {
|
|
277
|
+
filePath = path.join(os.homedir(), filePath.slice(1));
|
|
278
|
+
}
|
|
279
|
+
if (path.isAbsolute(filePath)) return filePath;
|
|
280
|
+
if (base && typeof base === 'string' && path.isAbsolute(base)) {
|
|
281
|
+
let baseDir = base;
|
|
282
|
+
try { if (fs.statSync(base).isFile()) baseDir = path.dirname(base); }
|
|
283
|
+
catch { if (path.extname(base)) baseDir = path.dirname(base); }
|
|
284
|
+
return path.resolve(baseDir, filePath);
|
|
285
|
+
}
|
|
286
|
+
return path.resolve(filePath);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
app.get('/api/preview', async (req, res) => {
|
|
290
|
+
try {
|
|
291
|
+
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
292
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
293
|
+
const content = await readMarkdownFile(abs);
|
|
294
|
+
res.json({ path: abs, content });
|
|
295
|
+
} catch (err) {
|
|
296
|
+
res.status(err.status || 500).json({ error: err.message || 'Preview failed' });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
app.post('/api/preview', async (req, res) => {
|
|
301
|
+
try {
|
|
302
|
+
const { path: filePath, sessionId, base, link } = req.body || {};
|
|
303
|
+
const abs = resolvePreviewPath(filePath, base);
|
|
304
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
305
|
+
const content = await readMarkdownFile(abs);
|
|
306
|
+
sseSend({ type: 'preview:open', path: abs, content, sessionId: sessionId || null, link: !!link });
|
|
307
|
+
res.json({ success: true });
|
|
308
|
+
} catch (err) {
|
|
309
|
+
res.status(err.status || 500).json({ error: err.message || 'Preview failed' });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
app.post('/api/session/pin', (req, res) => {
|
|
314
|
+
const { id, state } = req.body || {};
|
|
315
|
+
if (!id) return res.status(400).json({ error: 'id required' });
|
|
316
|
+
sseSend({ type: 'session:pin', id, state: state || 'pinned' });
|
|
317
|
+
res.json({ ok: true });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
app.post('/api/session/open', (req, res) => {
|
|
321
|
+
const { id } = req.body || {};
|
|
322
|
+
if (!id) return res.status(400).json({ error: 'id required' });
|
|
323
|
+
sseSend({ type: 'session:open', id });
|
|
324
|
+
res.json({ ok: true });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
function openInEditor(...targets) {
|
|
328
|
+
const editor = process.env.EDITOR || 'code';
|
|
329
|
+
spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
app.post('/api/open-in-editor', (req, res) => {
|
|
333
|
+
try {
|
|
334
|
+
const { content, title, file } = req.body || {};
|
|
335
|
+
if (file) { openInEditor(file); return res.json({ success: true, path: file }); }
|
|
336
|
+
if (!content) return res.status(400).json({ error: 'No content provided' });
|
|
337
|
+
const safeName = (title || 'message').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
|
|
338
|
+
const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
|
|
339
|
+
const tmp = path.join(os.tmpdir(), `pi-kanban-${safeName}-${hash}.md`);
|
|
340
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
341
|
+
openInEditor(tmp);
|
|
342
|
+
res.json({ success: true, path: tmp });
|
|
343
|
+
} catch (err) {
|
|
344
|
+
res.status(500).json({ error: String(err) });
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
app.post('/api/sessions/:id/agents/:agentId/stop', (_req, res) => res.json({ ok: true }));
|
|
348
|
+
|
|
349
|
+
// SSE — broadcast file changes as session-changed events.
|
|
350
|
+
const sseClients = new Set();
|
|
351
|
+
function sseSend(data) {
|
|
352
|
+
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
|
353
|
+
for (const r of sseClients) {
|
|
354
|
+
try { r.write(payload); } catch {}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
app.get('/api/events', (req, res) => {
|
|
359
|
+
res.set({
|
|
360
|
+
'Content-Type': 'text/event-stream',
|
|
361
|
+
'Cache-Control': 'no-cache',
|
|
362
|
+
Connection: 'keep-alive',
|
|
363
|
+
});
|
|
364
|
+
res.write(': connected\n\n');
|
|
365
|
+
sseClients.add(res);
|
|
366
|
+
const ka = setInterval(() => res.write(': ka\n\n'), 30000);
|
|
367
|
+
req.on('close', () => { clearInterval(ka); sseClients.delete(res); });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
app.get('/api/sessions/:id/agents/:agentId/messages/stream', async (req, res) => {
|
|
371
|
+
res.set({
|
|
372
|
+
'Content-Type': 'text/event-stream',
|
|
373
|
+
'Cache-Control': 'no-cache',
|
|
374
|
+
Connection: 'keep-alive',
|
|
375
|
+
});
|
|
376
|
+
res.write(': connected\n\n');
|
|
377
|
+
|
|
378
|
+
let watcherRef = null;
|
|
379
|
+
const ka = setInterval(() => { try { res.write(': ka\n\n'); } catch {} }, 30000);
|
|
380
|
+
req.on('close', () => {
|
|
381
|
+
clearInterval(ka);
|
|
382
|
+
if (watcherRef) { try { watcherRef.close(); } catch {} }
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
let meta;
|
|
386
|
+
try { meta = await parsers.findSessionFileById(req.params.id); } catch { meta = null; }
|
|
387
|
+
const rec = meta ? await parsers.findAgentRecord(meta, req.params.agentId).catch(() => null) : null;
|
|
388
|
+
if (!rec || !rec._sessionFile) return;
|
|
389
|
+
|
|
390
|
+
const send = async () => {
|
|
391
|
+
try {
|
|
392
|
+
const out = await parsers.buildMessages(rec._sessionFile, 50);
|
|
393
|
+
out.agentId = req.params.agentId;
|
|
394
|
+
res.write(`event: agent-log-update\ndata: ${JSON.stringify(out)}\n\n`);
|
|
395
|
+
} catch {}
|
|
396
|
+
};
|
|
397
|
+
await send();
|
|
398
|
+
watcherRef = chokidar.watch(rec._sessionFile, {
|
|
399
|
+
ignoreInitial: true,
|
|
400
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
|
401
|
+
});
|
|
402
|
+
watcherRef.on('change', send).on('add', send);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const watcher = chokidar.watch(path.join(parsers.getSessionsDir(), '**/*.jsonl'), {
|
|
406
|
+
ignoreInitial: true,
|
|
407
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 },
|
|
408
|
+
});
|
|
409
|
+
parsers.setOnBranchResolved((cwd, branch) => {
|
|
410
|
+
sseSend({ type: 'branch:resolved', cwd, branch });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
watcher.on('add', (f) => sseSend({ type: 'update', sessionId: parsers.slugFromFile(f) }));
|
|
414
|
+
watcher.on('change', (f) => sseSend({ type: 'update', sessionId: parsers.slugFromFile(f) }));
|
|
415
|
+
watcher.on('unlink', (f) => sseSend({ type: 'update', sessionId: parsers.slugFromFile(f) }));
|
|
416
|
+
|
|
417
|
+
const taskWatcher = chokidar.watch(taskStore.getTasksDir(), {
|
|
418
|
+
ignoreInitial: true,
|
|
419
|
+
depth: 2,
|
|
420
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
421
|
+
});
|
|
422
|
+
const tasksRoot = path.basename(taskStore.getTasksDir());
|
|
423
|
+
const pendingTaskSse = new Map();
|
|
424
|
+
function queueTaskUpdate(sid) {
|
|
425
|
+
if (pendingTaskSse.has(sid)) return;
|
|
426
|
+
pendingTaskSse.set(sid, setTimeout(() => {
|
|
427
|
+
pendingTaskSse.delete(sid);
|
|
428
|
+
sseSend({ type: 'task:update', sessionId: sid });
|
|
429
|
+
}, 75));
|
|
430
|
+
}
|
|
431
|
+
taskWatcher.on('all', (_evt, file) => {
|
|
432
|
+
if (!file || !file.endsWith('.json')) return;
|
|
433
|
+
const sid = path.basename(path.dirname(file));
|
|
434
|
+
if (sid && sid !== tasksRoot) queueTaskUpdate(sid);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const http = require('node:http');
|
|
438
|
+
const httpServer = http.createServer({ maxHeaderSize: 64 * 1024 }, app);
|
|
439
|
+
const server = httpServer.listen(PORT, async () => {
|
|
440
|
+
const url = `http://localhost:${PORT}`;
|
|
441
|
+
console.log(`pi-kanban listening at ${url}`);
|
|
442
|
+
console.log(`watching ${parsers.getSessionsDir()}`);
|
|
443
|
+
await loadAllThemes();
|
|
444
|
+
console.log(`themes loaded: ${themes.size} (user dir: ${USER_THEME_DIR})`);
|
|
445
|
+
if (shouldOpen) open(url).catch(() => {});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
process.on('SIGINT', () => { watcher.close(); taskWatcher.close(); server.close(() => process.exit(0)); });
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kanban-dark-default",
|
|
3
|
+
"displayName": "Kanban Default (Dark)",
|
|
4
|
+
"mode": "dark",
|
|
5
|
+
"colors": {
|
|
6
|
+
"bgDeep": "#101114",
|
|
7
|
+
"bgSurface": "#16181c",
|
|
8
|
+
"bgElevated": "#1e2025",
|
|
9
|
+
"bgHover": "#282a30",
|
|
10
|
+
"border": "#363840",
|
|
11
|
+
"textPrimary": "#f0f1f3",
|
|
12
|
+
"textSecondary": "#c2c4c9",
|
|
13
|
+
"textTertiary": "#9a9da5",
|
|
14
|
+
"textMuted": "#7d808a",
|
|
15
|
+
"accent": "#e86f33",
|
|
16
|
+
"accentText": "#f0a070",
|
|
17
|
+
"success": "#3ecf8e",
|
|
18
|
+
"warning": "#f0b429",
|
|
19
|
+
"plan": "#86a886"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kanban-default",
|
|
3
|
+
"displayName": "Kanban Default (Light)",
|
|
4
|
+
"mode": "light",
|
|
5
|
+
"colors": {
|
|
6
|
+
"bgDeep": "#e8e6e3",
|
|
7
|
+
"bgSurface": "#f4f3f1",
|
|
8
|
+
"bgElevated": "#dddbd8",
|
|
9
|
+
"bgHover": "#d2d0cc",
|
|
10
|
+
"border": "#a09b94",
|
|
11
|
+
"textPrimary": "#0a0a0a",
|
|
12
|
+
"textSecondary": "#444444",
|
|
13
|
+
"textTertiary": "#666666",
|
|
14
|
+
"textMuted": "#888888",
|
|
15
|
+
"accent": "#e86f33",
|
|
16
|
+
"accentText": "#b85a20",
|
|
17
|
+
"success": "#1a8a5a",
|
|
18
|
+
"warning": "#b07d0a",
|
|
19
|
+
"plan": "#5a7a5a"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-dark",
|
|
3
|
+
"displayName": "pi.dev Dark",
|
|
4
|
+
"mode": "dark",
|
|
5
|
+
"colors": {
|
|
6
|
+
"bgDeep": "#0d1116",
|
|
7
|
+
"bgSurface": "#161d27",
|
|
8
|
+
"bgElevated": "#212730",
|
|
9
|
+
"bgHover": "#252f3d",
|
|
10
|
+
"border": "#495059",
|
|
11
|
+
"textPrimary": "#ebe7e4",
|
|
12
|
+
"textSecondary": "#d5d8db",
|
|
13
|
+
"textTertiary": "#9fa4ab",
|
|
14
|
+
"textMuted": "#757d89",
|
|
15
|
+
"accent": "#6a9fcc",
|
|
16
|
+
"accentText": "#8fb6d8",
|
|
17
|
+
"success": "#4ade80",
|
|
18
|
+
"warning": "#fbbf24",
|
|
19
|
+
"plan": "#c084fc"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-light",
|
|
3
|
+
"displayName": "pi.dev Light",
|
|
4
|
+
"mode": "light",
|
|
5
|
+
"colors": {
|
|
6
|
+
"bgDeep": "#ebe7e4",
|
|
7
|
+
"bgSurface": "#f4f1ee",
|
|
8
|
+
"bgElevated": "#e0dcd8",
|
|
9
|
+
"bgHover": "#d5d1cd",
|
|
10
|
+
"border": "#b5b0aa",
|
|
11
|
+
"textPrimary": "#0d1116",
|
|
12
|
+
"textSecondary": "#212730",
|
|
13
|
+
"textTertiary": "#495059",
|
|
14
|
+
"textMuted": "#757d89",
|
|
15
|
+
"accent": "#2f5f8a",
|
|
16
|
+
"accentText": "#1d4870",
|
|
17
|
+
"success": "#15803d",
|
|
18
|
+
"warning": "#b45309",
|
|
19
|
+
"plan": "#7c3aed"
|
|
20
|
+
}
|
|
21
|
+
}
|