@pat-lewczuk/cezar 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 +90 -0
- package/dist/core/agent-runner.d.ts +95 -0
- package/dist/core/agent-runner.js +8 -0
- package/dist/core/agent-runner.js.map +1 -0
- package/dist/core/backend-detect.d.ts +14 -0
- package/dist/core/backend-detect.js +78 -0
- package/dist/core/backend-detect.js.map +1 -0
- package/dist/core/claude-cli-runner.d.ts +68 -0
- package/dist/core/claude-cli-runner.js +424 -0
- package/dist/core/claude-cli-runner.js.map +1 -0
- package/dist/core/usage.d.ts +11 -0
- package/dist/core/usage.js +15 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +294 -0
- package/dist/index.js.map +1 -0
- package/dist/runs/store.d.ts +218 -0
- package/dist/runs/store.js +286 -0
- package/dist/runs/store.js.map +1 -0
- package/dist/server/git.d.ts +21 -0
- package/dist/server/git.js +54 -0
- package/dist/server/git.js.map +1 -0
- package/dist/server/open-in-terminal.d.ts +11 -0
- package/dist/server/open-in-terminal.js +84 -0
- package/dist/server/open-in-terminal.js.map +1 -0
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.js +243 -0
- package/dist/server/server.js.map +1 -0
- package/dist/skills.d.ts +31 -0
- package/dist/skills.js +117 -0
- package/dist/skills.js.map +1 -0
- package/dist/workflows/load.d.ts +15 -0
- package/dist/workflows/load.js +58 -0
- package/dist/workflows/load.js.map +1 -0
- package/dist/workflows/run.d.ts +52 -0
- package/dist/workflows/run.js +452 -0
- package/dist/workflows/run.js.map +1 -0
- package/dist/workflows/types.d.ts +202 -0
- package/dist/workflows/types.js +56 -0
- package/dist/workflows/types.js.map +1 -0
- package/package.json +44 -0
- package/scripts/mock-claude.mjs +106 -0
- package/web/app.js +634 -0
- package/web/index.html +48 -0
- package/web/style.css +317 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* cez cockpit — vanilla JS, no build step. Talks to the local server via
|
|
4
|
+
fetch + Server-Sent Events. */
|
|
5
|
+
|
|
6
|
+
const $ = (sel, el = document) => el.querySelector(sel);
|
|
7
|
+
|
|
8
|
+
const state = {
|
|
9
|
+
runs: new Map(), // id -> RunRecord
|
|
10
|
+
selectedId: null,
|
|
11
|
+
runEs: null, // per-run EventSource
|
|
12
|
+
lastSeq: 0, // dedup across SSE reconnect replays
|
|
13
|
+
autoScroll: true,
|
|
14
|
+
workflows: [],
|
|
15
|
+
pendingImages: [], // [{mediaType, data, preview}] queued for the next message
|
|
16
|
+
listView: 'active', // 'active' | 'archived'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const STATUS_ORDER = { waiting: 0, review: 1, running: 2, queued: 3 };
|
|
20
|
+
|
|
21
|
+
// ---- helpers ---------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function esc(s) {
|
|
24
|
+
return String(s ?? '')
|
|
25
|
+
.replaceAll('&', '&')
|
|
26
|
+
.replaceAll('<', '<')
|
|
27
|
+
.replaceAll('>', '>')
|
|
28
|
+
.replaceAll('"', '"');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function timeAgo(iso) {
|
|
32
|
+
if (!iso) return '';
|
|
33
|
+
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
|
34
|
+
if (s < 60) return `${Math.floor(s)}s ago`;
|
|
35
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
36
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
37
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fmtTokens(n) {
|
|
41
|
+
if (!n) return '0 tok';
|
|
42
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`;
|
|
43
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`;
|
|
44
|
+
return `${n} tok`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fmtCost(usd) {
|
|
48
|
+
if (!usd) return '';
|
|
49
|
+
return `$${usd >= 10 ? usd.toFixed(0) : usd.toFixed(2)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function prBadge(run) {
|
|
53
|
+
if (!run.pullRequestUrl) return '';
|
|
54
|
+
const num = run.pullRequestUrl.split('/').pop();
|
|
55
|
+
return `<a class="pr-badge" href="${esc(run.pullRequestUrl)}" target="_blank" rel="noopener">PR #${esc(num)}</a>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function badge(status) {
|
|
59
|
+
return `<span class="badge ${esc(status)}">${esc(status)}</span>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getJson(url) {
|
|
63
|
+
const res = await fetch(url);
|
|
64
|
+
if (!res.ok) throw new Error(`${url} → ${res.status}`);
|
|
65
|
+
return res.json();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- boot ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
async function init() {
|
|
71
|
+
bindUi();
|
|
72
|
+
const [health, workflowsRes, runs] = await Promise.all([
|
|
73
|
+
getJson('/api/health'),
|
|
74
|
+
getJson('/api/workflows'),
|
|
75
|
+
getJson('/api/runs'),
|
|
76
|
+
]);
|
|
77
|
+
renderHeader(health);
|
|
78
|
+
state.workflows = workflowsRes.workflows;
|
|
79
|
+
const select = $('#new-task select[name=workflow]');
|
|
80
|
+
select.innerHTML = state.workflows
|
|
81
|
+
.map((w) => `<option value="${esc(w.name)}" title="${esc(w.description ?? '')}">${esc(w.name)}</option>`)
|
|
82
|
+
.join('');
|
|
83
|
+
for (const run of runs) state.runs.set(run.id, run);
|
|
84
|
+
renderRunList();
|
|
85
|
+
connectGlobal();
|
|
86
|
+
const latest = sortedRuns()[0];
|
|
87
|
+
if (latest) selectRun(latest.id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderHeader(health) {
|
|
91
|
+
const repoChip = $('#repo-chip');
|
|
92
|
+
if (health.repo) {
|
|
93
|
+
repoChip.hidden = false;
|
|
94
|
+
repoChip.textContent = `${health.repo.root.split('/').pop()} · ${health.repo.branch}`;
|
|
95
|
+
}
|
|
96
|
+
$('#env-chips').innerHTML = health.checks
|
|
97
|
+
.map((c) => `<span class="chip ${c.available ? 'ok' : 'bad'}" title="${esc(c.hint ?? c.version ?? '')}">${c.available ? '✓' : '✗'} ${esc(c.name)}</span>`)
|
|
98
|
+
.join('');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function connectGlobal() {
|
|
102
|
+
const es = new EventSource('/api/events');
|
|
103
|
+
es.addEventListener('run', (e) => {
|
|
104
|
+
const run = JSON.parse(e.data);
|
|
105
|
+
state.runs.set(run.id, run);
|
|
106
|
+
renderRunList();
|
|
107
|
+
if (run.id === state.selectedId) updateDetail(run);
|
|
108
|
+
});
|
|
109
|
+
es.addEventListener('run-deleted', (e) => {
|
|
110
|
+
const { id } = JSON.parse(e.data);
|
|
111
|
+
state.runs.delete(id);
|
|
112
|
+
renderRunList();
|
|
113
|
+
if (id === state.selectedId) {
|
|
114
|
+
state.selectedId = null;
|
|
115
|
+
$('#detail').innerHTML = '<div class="empty">Select a run — or start one.</div>';
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- UI events -------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function bindUi() {
|
|
123
|
+
$('#tabs').addEventListener('click', (e) => {
|
|
124
|
+
const btn = e.target.closest('button[data-view]');
|
|
125
|
+
if (!btn) return;
|
|
126
|
+
for (const b of $('#tabs').children) b.classList.toggle('active', b === btn);
|
|
127
|
+
for (const view of document.querySelectorAll('.view')) view.hidden = true;
|
|
128
|
+
const view = $(`#view-${btn.dataset.view}`);
|
|
129
|
+
view.hidden = false;
|
|
130
|
+
if (btn.dataset.view === 'repo') loadRepo();
|
|
131
|
+
if (btn.dataset.view === 'skills') loadSkills();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
$('#new-task').addEventListener('submit', async (e) => {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
const form = e.target;
|
|
137
|
+
const errorBox = $('#form-error');
|
|
138
|
+
errorBox.hidden = true;
|
|
139
|
+
const body = {
|
|
140
|
+
task: form.task.value.trim(),
|
|
141
|
+
workflow: form.workflow.value,
|
|
142
|
+
model: form.model.value.trim() || undefined,
|
|
143
|
+
};
|
|
144
|
+
if (!body.task) return;
|
|
145
|
+
form.querySelector('button').disabled = true;
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch('/api/runs', {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'content-type': 'application/json' },
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
});
|
|
152
|
+
const data = await res.json();
|
|
153
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
154
|
+
form.task.value = '';
|
|
155
|
+
state.runs.set(data.id, data);
|
|
156
|
+
renderRunList();
|
|
157
|
+
selectRun(data.id);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
errorBox.textContent = err.message;
|
|
160
|
+
errorBox.hidden = false;
|
|
161
|
+
} finally {
|
|
162
|
+
form.querySelector('button').disabled = false;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
$('#run-list').addEventListener('click', (e) => {
|
|
167
|
+
if (e.target.closest('a')) return; // PR links navigate, not select
|
|
168
|
+
const item = e.target.closest('.run-item');
|
|
169
|
+
if (item) selectRun(item.dataset.id);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
$('#list-tabs').addEventListener('click', async (e) => {
|
|
173
|
+
const btn = e.target.closest('button[data-list]');
|
|
174
|
+
if (!btn) return;
|
|
175
|
+
if (btn.dataset.list === 'archive-finished') {
|
|
176
|
+
await fetch('/api/runs/archive-finished', { method: 'POST' });
|
|
177
|
+
return; // SSE run updates re-render the list
|
|
178
|
+
}
|
|
179
|
+
state.listView = btn.dataset.list;
|
|
180
|
+
renderRunList();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
$('#detail').addEventListener('click', async (e) => {
|
|
184
|
+
const btn = e.target.closest('button[data-action]');
|
|
185
|
+
if (!btn) return;
|
|
186
|
+
const id = state.selectedId;
|
|
187
|
+
if (!id) return;
|
|
188
|
+
if (btn.dataset.action === 'cancel') {
|
|
189
|
+
await fetch(`/api/runs/${id}/cancel`, { method: 'POST' });
|
|
190
|
+
} else if (btn.dataset.action === 'delete') {
|
|
191
|
+
if (!confirm('Delete this run and its log?')) return;
|
|
192
|
+
await fetch(`/api/runs/${id}`, { method: 'DELETE' });
|
|
193
|
+
} else if (btn.dataset.action === 'finish') {
|
|
194
|
+
await fetch(`/api/runs/${id}/finish`, { method: 'POST' });
|
|
195
|
+
} else if (btn.dataset.action === 'archive') {
|
|
196
|
+
const run = state.runs.get(id);
|
|
197
|
+
await fetch(`/api/runs/${id}/archive`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'content-type': 'application/json' },
|
|
200
|
+
body: JSON.stringify({ archived: !run?.archived }),
|
|
201
|
+
});
|
|
202
|
+
} else if (btn.dataset.action === 'continue') {
|
|
203
|
+
const res = await fetch(`/api/runs/${id}/continue`, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'content-type': 'application/json' },
|
|
206
|
+
body: JSON.stringify({}),
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok) {
|
|
209
|
+
const data = await res.json().catch(() => ({}));
|
|
210
|
+
alertBar(data.error ?? 'cannot continue');
|
|
211
|
+
}
|
|
212
|
+
} else if (btn.dataset.action === 'open-cli') {
|
|
213
|
+
const res = await fetch(`/api/runs/${id}/open-in-cli`, { method: 'POST' });
|
|
214
|
+
const data = await res.json().catch(() => ({}));
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
if (data.command) {
|
|
217
|
+
try {
|
|
218
|
+
await navigator.clipboard.writeText(data.command);
|
|
219
|
+
alertBar('No terminal found — command copied to clipboard.');
|
|
220
|
+
} catch {
|
|
221
|
+
alertBar(`Run manually: ${data.command}`);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
alertBar(data.error ?? 'cannot open terminal');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} else if (btn.dataset.action === 'jump-bottom') {
|
|
228
|
+
state.autoScroll = true;
|
|
229
|
+
const log = $('#log');
|
|
230
|
+
if (log) log.scrollTop = log.scrollHeight;
|
|
231
|
+
btn.hidden = true;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Quick replies for agent questions (janitor-style): Alt+A approve, Alt+C continue.
|
|
236
|
+
document.addEventListener('keydown', (e) => {
|
|
237
|
+
if (!e.altKey || !state.selectedId) return;
|
|
238
|
+
if (e.code === 'KeyA') void sendMessage(state.selectedId, 'Yes, approved.', []);
|
|
239
|
+
if (e.code === 'KeyC') void sendMessage(state.selectedId, 'Continue.', []);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---- live-session message bar ------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function bindMessageBar(runId) {
|
|
246
|
+
const form = $('#msg-form');
|
|
247
|
+
const textarea = $('#msg-text');
|
|
248
|
+
const fileInput = $('#msg-file');
|
|
249
|
+
|
|
250
|
+
form.addEventListener('submit', async (e) => {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
const text = textarea.value.trim();
|
|
253
|
+
const images = state.pendingImages.map((i) => ({ mediaType: i.mediaType, data: i.data }));
|
|
254
|
+
if (!text && images.length === 0) return;
|
|
255
|
+
const ok = await sendMessage(runId, text, images);
|
|
256
|
+
if (ok) {
|
|
257
|
+
textarea.value = '';
|
|
258
|
+
state.pendingImages = [];
|
|
259
|
+
renderThumbs();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
textarea.addEventListener('keydown', (e) => {
|
|
264
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
form.requestSubmit();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
textarea.addEventListener('paste', (e) => {
|
|
271
|
+
const items = [...(e.clipboardData?.items ?? [])].filter((i) => i.type.startsWith('image/'));
|
|
272
|
+
if (!items.length) return;
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
for (const item of items) {
|
|
275
|
+
const file = item.getAsFile();
|
|
276
|
+
if (file) void addImage(file);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
$('#msg-attach').addEventListener('click', () => fileInput.click());
|
|
281
|
+
fileInput.addEventListener('change', () => {
|
|
282
|
+
for (const file of fileInput.files ?? []) void addImage(file);
|
|
283
|
+
fileInput.value = '';
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
$('#msg-thumbs').addEventListener('click', (e) => {
|
|
287
|
+
const thumb = e.target.closest('[data-idx]');
|
|
288
|
+
if (!thumb) return;
|
|
289
|
+
state.pendingImages.splice(Number(thumb.dataset.idx), 1);
|
|
290
|
+
renderThumbs();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function addImage(file) {
|
|
295
|
+
if (state.pendingImages.length >= 4) return;
|
|
296
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
297
|
+
alertBar('Image too large (max 5 MB)');
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const buf = await file.arrayBuffer();
|
|
301
|
+
let binary = '';
|
|
302
|
+
const bytes = new Uint8Array(buf);
|
|
303
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
304
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
305
|
+
}
|
|
306
|
+
const data = btoa(binary);
|
|
307
|
+
state.pendingImages.push({ mediaType: file.type, data, preview: `data:${file.type};base64,${data}` });
|
|
308
|
+
renderThumbs();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderThumbs() {
|
|
312
|
+
const box = $('#msg-thumbs');
|
|
313
|
+
if (!box) return;
|
|
314
|
+
box.hidden = state.pendingImages.length === 0;
|
|
315
|
+
box.innerHTML = state.pendingImages
|
|
316
|
+
.map((img, idx) => `<span class="thumb" data-idx="${idx}" title="Click to remove"><img src="${img.preview}"></span>`)
|
|
317
|
+
.join('');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function sendMessage(runId, text, images) {
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch(`/api/runs/${runId}/messages`, {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
headers: { 'content-type': 'application/json' },
|
|
325
|
+
body: JSON.stringify({ text, images }),
|
|
326
|
+
});
|
|
327
|
+
if (res.status === 409) {
|
|
328
|
+
alertBar('Session closed — the agent is no longer listening.');
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
if (!res.ok) {
|
|
332
|
+
const data = await res.json().catch(() => ({}));
|
|
333
|
+
alertBar(data.error ?? `HTTP ${res.status}`);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
} catch (err) {
|
|
338
|
+
alertBar(err.message);
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function alertBar(message) {
|
|
344
|
+
const existing = $('#toast');
|
|
345
|
+
if (existing) existing.remove();
|
|
346
|
+
const el = document.createElement('div');
|
|
347
|
+
el.id = 'toast';
|
|
348
|
+
el.textContent = message;
|
|
349
|
+
document.body.appendChild(el);
|
|
350
|
+
setTimeout(() => el.remove(), 4000);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---- run list --------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
function sortedRuns() {
|
|
356
|
+
// Needs-you-first: waiting/review, then running/queued, then by recency.
|
|
357
|
+
return [...state.runs.values()]
|
|
358
|
+
.filter((r) => (state.listView === 'archived' ? r.archived : !r.archived))
|
|
359
|
+
.sort((a, b) => {
|
|
360
|
+
const pa = STATUS_ORDER[a.status] ?? 9;
|
|
361
|
+
const pb = STATUS_ORDER[b.status] ?? 9;
|
|
362
|
+
if (pa !== pb) return pa - pb;
|
|
363
|
+
return b.createdAt.localeCompare(a.createdAt);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderRunList() {
|
|
368
|
+
const all = [...state.runs.values()];
|
|
369
|
+
const activeCount = all.filter((r) => !r.archived).length;
|
|
370
|
+
const archivedCount = all.length - activeCount;
|
|
371
|
+
const finishedCount = all.filter(
|
|
372
|
+
(r) => !r.archived && ['done', 'failed', 'cancelled'].includes(r.status),
|
|
373
|
+
).length;
|
|
374
|
+
const waitingCount = all.filter((r) => !r.archived && r.status === 'waiting').length;
|
|
375
|
+
|
|
376
|
+
$('#list-tabs').innerHTML = `
|
|
377
|
+
<button data-list="active" class="${state.listView === 'active' ? 'active' : ''}">
|
|
378
|
+
Active ${activeCount ? `(${activeCount})` : ''}${waitingCount ? ' <span class="dot"></span>' : ''}
|
|
379
|
+
</button>
|
|
380
|
+
<button data-list="archived" class="${state.listView === 'archived' ? 'active' : ''}">
|
|
381
|
+
Archived ${archivedCount ? `(${archivedCount})` : ''}
|
|
382
|
+
</button>
|
|
383
|
+
${state.listView === 'active' && finishedCount ? `<button data-list="archive-finished" title="Archive all finished runs">📥 ${finishedCount}</button>` : ''}`;
|
|
384
|
+
|
|
385
|
+
$('#run-list').innerHTML = sortedRuns()
|
|
386
|
+
.map(
|
|
387
|
+
(r) => `
|
|
388
|
+
<div class="run-item ${r.id === state.selectedId ? 'selected' : ''}" data-id="${r.id}">
|
|
389
|
+
<div class="title" title="${esc(r.task)}">${esc(r.title)}</div>
|
|
390
|
+
<div class="meta">${badge(r.status)} ${prBadge(r)} <span>${esc(r.workflow)}</span> <span>${fmtTokens(r.tokensUsed)}</span> ${r.costUsd ? `<span>${fmtCost(r.costUsd)}</span>` : ''} <span>${timeAgo(r.createdAt)}</span></div>
|
|
391
|
+
</div>`,
|
|
392
|
+
)
|
|
393
|
+
.join('');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---- run detail ------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
function selectRun(id) {
|
|
399
|
+
const run = state.runs.get(id);
|
|
400
|
+
if (!run) return;
|
|
401
|
+
state.selectedId = id;
|
|
402
|
+
state.lastSeq = 0;
|
|
403
|
+
state.autoScroll = true;
|
|
404
|
+
if (state.runEs) {
|
|
405
|
+
state.runEs.close();
|
|
406
|
+
state.runEs = null;
|
|
407
|
+
}
|
|
408
|
+
renderRunList();
|
|
409
|
+
renderDetailShell(run);
|
|
410
|
+
|
|
411
|
+
const es = new EventSource(`/api/runs/${id}/events`);
|
|
412
|
+
state.runEs = es;
|
|
413
|
+
es.addEventListener('run-event', (e) => {
|
|
414
|
+
const evt = JSON.parse(e.data);
|
|
415
|
+
if (evt.seq <= state.lastSeq) return; // reconnect replay dedup
|
|
416
|
+
state.lastSeq = evt.seq;
|
|
417
|
+
appendLog(evt);
|
|
418
|
+
});
|
|
419
|
+
es.addEventListener('run', (e) => updateDetail(JSON.parse(e.data)));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function renderDetailShell(run) {
|
|
423
|
+
state.pendingImages = [];
|
|
424
|
+
$('#detail').innerHTML = `
|
|
425
|
+
<div class="detail-header" id="detail-header"></div>
|
|
426
|
+
<div class="steps-rail" id="steps-rail"></div>
|
|
427
|
+
<div class="log-wrap">
|
|
428
|
+
<div id="log"></div>
|
|
429
|
+
<button id="jump-bottom" data-action="jump-bottom" hidden>↓ jump to bottom</button>
|
|
430
|
+
</div>
|
|
431
|
+
<form id="msg-form">
|
|
432
|
+
<div id="msg-thumbs" hidden></div>
|
|
433
|
+
<div class="msg-row">
|
|
434
|
+
<button type="button" id="msg-attach" title="Attach an image">📎</button>
|
|
435
|
+
<textarea id="msg-text" rows="1" placeholder="Message the agent… (Enter to send, ⌘V to paste a screenshot)"></textarea>
|
|
436
|
+
<button type="submit" id="msg-send" class="primary">➤</button>
|
|
437
|
+
</div>
|
|
438
|
+
<input type="file" id="msg-file" accept="image/*" multiple hidden>
|
|
439
|
+
</form>`;
|
|
440
|
+
bindMessageBar(run.id);
|
|
441
|
+
updateDetail(run);
|
|
442
|
+
$('#log').addEventListener('scroll', () => {
|
|
443
|
+
const log = $('#log');
|
|
444
|
+
const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
445
|
+
state.autoScroll = nearBottom;
|
|
446
|
+
$('#jump-bottom').hidden = nearBottom;
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function updateDetail(run) {
|
|
451
|
+
state.runs.set(run.id, run);
|
|
452
|
+
const active = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
|
|
453
|
+
const lastSession = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
|
|
454
|
+
$('#detail-header').innerHTML = `
|
|
455
|
+
${badge(run.status)}
|
|
456
|
+
<h2 title="${esc(run.task)}">${esc(run.title)}</h2>
|
|
457
|
+
${run.status === 'waiting' ? '<button data-action="finish">✓ Finish</button>' : ''}
|
|
458
|
+
${!active && lastSession ? '<button data-action="continue">▶ Continue</button><button data-action="open-cli">⌨ Open in terminal</button>' : ''}
|
|
459
|
+
${!active ? `<button data-action="archive" title="${run.archived ? 'Unarchive' : 'Archive'}">${run.archived ? '📤' : '📥'}</button>` : ''}
|
|
460
|
+
${active
|
|
461
|
+
? '<button data-action="cancel" class="danger">■ Cancel</button>'
|
|
462
|
+
: '<button data-action="delete" class="danger">🗑 Delete</button>'}
|
|
463
|
+
<div class="sub">
|
|
464
|
+
${esc(run.workflow)} ${prBadge(run)} · ${fmtTokens(run.tokensUsed)}${run.costUsd ? ` · ${fmtCost(run.costUsd)}` : ''} · started ${timeAgo(run.startedAt ?? run.createdAt)}${run.finishedAt ? ` · finished ${timeAgo(run.finishedAt)}` : ''}${run.model ? ` · model ${esc(run.model)}` : ''}
|
|
465
|
+
${run.error ? `<br>✗ ${esc(run.error)}` : ''}
|
|
466
|
+
${!active && lastSession ? `<br>take over interactively: claude --resume ${esc(lastSession)}` : ''}
|
|
467
|
+
</div>`;
|
|
468
|
+
const msgForm = $('#msg-form');
|
|
469
|
+
if (msgForm) {
|
|
470
|
+
const sessionOpen = run.status === 'running' || run.status === 'waiting';
|
|
471
|
+
msgForm.classList.toggle('disabled', !sessionOpen);
|
|
472
|
+
$('#msg-text').disabled = !sessionOpen;
|
|
473
|
+
$('#msg-send').disabled = !sessionOpen;
|
|
474
|
+
$('#msg-attach').disabled = !sessionOpen;
|
|
475
|
+
$('#msg-text').placeholder = sessionOpen
|
|
476
|
+
? run.status === 'waiting'
|
|
477
|
+
? 'The agent is waiting for you… (Enter to send)'
|
|
478
|
+
: 'Message the agent… (Enter to send, ⌘V to paste a screenshot)'
|
|
479
|
+
: 'Session closed.';
|
|
480
|
+
}
|
|
481
|
+
$('#steps-rail').innerHTML = run.steps
|
|
482
|
+
.map(
|
|
483
|
+
(s) => `
|
|
484
|
+
<div class="step-card" title="${esc(s.error ?? '')}${s.sessionId ? `\nsession: ${esc(s.sessionId)}` : ''}">
|
|
485
|
+
${badge(s.status)}
|
|
486
|
+
<span class="name">${esc(s.name)}</span>
|
|
487
|
+
<span class="dim">${s.kind}${s.iterations > 1 ? ` ×${s.iterations}` : ''}</span>
|
|
488
|
+
<span class="tok">${fmtTokens(s.tokensUsed)}</span>
|
|
489
|
+
</div>`,
|
|
490
|
+
)
|
|
491
|
+
.join('');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function appendLog(evt) {
|
|
495
|
+
const log = $('#log');
|
|
496
|
+
if (!log) return;
|
|
497
|
+
const el = document.createElement('div');
|
|
498
|
+
el.className = `ev ${evt.type}${evt.isError ? ' error' : ''}`;
|
|
499
|
+
|
|
500
|
+
switch (evt.type) {
|
|
501
|
+
case 'text':
|
|
502
|
+
el.textContent = evt.text ?? '';
|
|
503
|
+
break;
|
|
504
|
+
case 'tool-call': {
|
|
505
|
+
const input = JSON.stringify(evt.input ?? {}, null, 2);
|
|
506
|
+
const preview = input.length > 100 ? `${input.slice(0, 97).replaceAll('\n', ' ')}…` : input.replaceAll('\n', ' ');
|
|
507
|
+
el.innerHTML =
|
|
508
|
+
input.length > 100
|
|
509
|
+
? `→ ${esc(evt.tool)} <details><summary>${esc(preview)}</summary><pre>${esc(input)}</pre></details>`
|
|
510
|
+
: `→ ${esc(evt.tool)} ${esc(preview)}`;
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
case 'tool-result': {
|
|
514
|
+
const result = String(evt.result ?? '');
|
|
515
|
+
const first = result.split('\n')[0] ?? '';
|
|
516
|
+
el.innerHTML =
|
|
517
|
+
result.length > first.length
|
|
518
|
+
? `← <details><summary>${esc(first.slice(0, 140))}${result.length > 140 ? '…' : ''}</summary><pre>${esc(result.slice(0, 10_000))}</pre></details>`
|
|
519
|
+
: `← ${esc(first)}`;
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
case 'check-output':
|
|
523
|
+
el.textContent = `${evt.text ?? ''}\n(exit ${evt.exitCode})`;
|
|
524
|
+
break;
|
|
525
|
+
case 'step-start':
|
|
526
|
+
el.textContent = `▸ ${evt.name}${evt.iteration > 1 ? ` (attempt ${evt.iteration})` : ''}`;
|
|
527
|
+
break;
|
|
528
|
+
case 'step-end':
|
|
529
|
+
el.className = 'ev note';
|
|
530
|
+
el.textContent = `step ${evt.stepId}: ${evt.status}${evt.error ? ` — ${evt.error}` : ''}`;
|
|
531
|
+
break;
|
|
532
|
+
case 'note':
|
|
533
|
+
case 'lifecycle':
|
|
534
|
+
el.textContent = `· ${evt.message ?? ''}`;
|
|
535
|
+
break;
|
|
536
|
+
case 'user-message': {
|
|
537
|
+
const imgs = evt.imageCount > 0 ? ` [${evt.imageCount} image${evt.imageCount > 1 ? 's' : ''}]` : '';
|
|
538
|
+
el.textContent = `you: ${evt.text ?? ''}${imgs}`;
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
case 'error':
|
|
542
|
+
el.textContent = `✗ ${evt.message ?? ''}`;
|
|
543
|
+
break;
|
|
544
|
+
case 'token-usage':
|
|
545
|
+
case 'cost':
|
|
546
|
+
case 'turn-end':
|
|
547
|
+
return; // reflected in the header/steps already
|
|
548
|
+
case 'done':
|
|
549
|
+
return;
|
|
550
|
+
default:
|
|
551
|
+
el.textContent = JSON.stringify(evt);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
log.appendChild(el);
|
|
555
|
+
if (state.autoScroll) log.scrollTop = log.scrollHeight;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ---- repo view ---------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
async function loadRepo() {
|
|
561
|
+
const view = $('#view-repo');
|
|
562
|
+
view.innerHTML = '<div class="dim">Loading…</div>';
|
|
563
|
+
try {
|
|
564
|
+
const [repo, diff] = await Promise.all([
|
|
565
|
+
getJson('/api/repo'),
|
|
566
|
+
fetch('/api/repo/diff').then((r) => r.text()),
|
|
567
|
+
]);
|
|
568
|
+
if (!repo.info) {
|
|
569
|
+
view.innerHTML = '<div class="panel">Not a git repository.</div>';
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
view.innerHTML = `
|
|
573
|
+
<div class="panel">
|
|
574
|
+
<h3>Repository</h3>
|
|
575
|
+
<div class="mono">${esc(repo.info.root)} · <b>${esc(repo.info.branch)}</b>${repo.info.remote ? ` · ${esc(repo.info.remote)}` : ''}</div>
|
|
576
|
+
</div>
|
|
577
|
+
<div class="panel">
|
|
578
|
+
<h3>Working tree (${repo.status.length} changed)</h3>
|
|
579
|
+
${repo.status.length
|
|
580
|
+
? `<table>${repo.status.map((s) => `<tr><td class="mono dim">${esc(s.status)}</td><td class="mono">${esc(s.path)}</td></tr>`).join('')}</table>`
|
|
581
|
+
: '<div class="dim">clean</div>'}
|
|
582
|
+
</div>
|
|
583
|
+
<div class="panel">
|
|
584
|
+
<h3>Diff vs HEAD</h3>
|
|
585
|
+
${diff.trim() ? `<pre>${esc(diff)}</pre>` : '<div class="dim">no changes</div>'}
|
|
586
|
+
</div>
|
|
587
|
+
<div class="panel">
|
|
588
|
+
<h3>Recent commits</h3>
|
|
589
|
+
<table>${repo.log.map((l) => `<tr><td class="mono dim">${esc(l.hash)}</td><td>${esc(l.subject)}</td><td class="dim">${esc(l.author)}</td><td class="dim">${esc(l.when)}</td></tr>`).join('')}</table>
|
|
590
|
+
</div>`;
|
|
591
|
+
} catch (err) {
|
|
592
|
+
view.innerHTML = `<div class="panel">✗ ${esc(err.message)}</div>`;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ---- skills view ---------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
async function loadSkills() {
|
|
599
|
+
const view = $('#view-skills');
|
|
600
|
+
view.innerHTML = '<div class="dim">Loading…</div>';
|
|
601
|
+
try {
|
|
602
|
+
const skills = await getJson('/api/skills');
|
|
603
|
+
if (!skills.length) {
|
|
604
|
+
view.innerHTML = `
|
|
605
|
+
<div class="panel">
|
|
606
|
+
<h3>Skills</h3>
|
|
607
|
+
<div>No skills yet. Drop Markdown files into <span class="mono">.ai/skills/</span> or <span class="mono">.ai/cezar/skills/</span> —
|
|
608
|
+
optional frontmatter: <span class="mono">name</span>, <span class="mono">description</span>.
|
|
609
|
+
Reference one from a workflow step via <span class="mono">skill: <name></span>.</div>
|
|
610
|
+
</div>`;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
view.innerHTML = skills
|
|
614
|
+
.map(
|
|
615
|
+
(s) => `
|
|
616
|
+
<div class="panel skill-card">
|
|
617
|
+
<h3>${esc(s.name)} <span class="dim">(${esc(s.source)})</span></h3>
|
|
618
|
+
${s.description ? `<div>${esc(s.description)}</div>` : ''}
|
|
619
|
+
<div class="path mono dim">${esc(s.path)}</div>
|
|
620
|
+
<details><summary class="dim">show body</summary><pre>${esc(s.body)}</pre></details>
|
|
621
|
+
</div>`,
|
|
622
|
+
)
|
|
623
|
+
.join('');
|
|
624
|
+
} catch (err) {
|
|
625
|
+
view.innerHTML = `<div class="panel">✗ ${esc(err.message)}</div>`;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
init().catch((err) => {
|
|
630
|
+
document.body.insertAdjacentHTML(
|
|
631
|
+
'beforeend',
|
|
632
|
+
`<div style="position:fixed;bottom:12px;left:12px;color:#f85149">init failed: ${esc(err.message)}</div>`,
|
|
633
|
+
);
|
|
634
|
+
});
|
package/web/index.html
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>cezar</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚡</text></svg>">
|
|
8
|
+
<link rel="stylesheet" href="/style.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header>
|
|
12
|
+
<div class="brand">⚡ cezar</div>
|
|
13
|
+
<div id="repo-chip" class="chip" hidden></div>
|
|
14
|
+
<div id="env-chips"></div>
|
|
15
|
+
<nav id="tabs">
|
|
16
|
+
<button data-view="runs" class="active">Runs</button>
|
|
17
|
+
<button data-view="repo">Repo</button>
|
|
18
|
+
<button data-view="skills">Skills</button>
|
|
19
|
+
</nav>
|
|
20
|
+
</header>
|
|
21
|
+
|
|
22
|
+
<main>
|
|
23
|
+
<section id="view-runs" class="view">
|
|
24
|
+
<aside id="sidebar">
|
|
25
|
+
<form id="new-task" autocomplete="off">
|
|
26
|
+
<textarea name="task" rows="3" placeholder="What should the agent do in this repo?" required></textarea>
|
|
27
|
+
<div class="row">
|
|
28
|
+
<select name="workflow" title="workflow"></select>
|
|
29
|
+
<input name="model" placeholder="model (optional)" title="model override, e.g. sonnet / opus">
|
|
30
|
+
</div>
|
|
31
|
+
<button type="submit" class="primary">▶ Run</button>
|
|
32
|
+
<div id="form-error" class="error-text" hidden></div>
|
|
33
|
+
</form>
|
|
34
|
+
<div id="list-tabs"></div>
|
|
35
|
+
<div id="run-list"></div>
|
|
36
|
+
</aside>
|
|
37
|
+
<section id="detail">
|
|
38
|
+
<div class="empty">Select a run — or start one.</div>
|
|
39
|
+
</section>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<section id="view-repo" class="view" hidden></section>
|
|
43
|
+
<section id="view-skills" class="view" hidden></section>
|
|
44
|
+
</main>
|
|
45
|
+
|
|
46
|
+
<script src="/app.js"></script>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|