@hanzlaa/rcode 4.1.2 → 4.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/cli/install.js +176 -13
- package/cli/lib/config.cjs +4 -2
- package/cli/lib/fsutil.cjs +13 -2
- package/cli/lib/homedir.cjs +21 -0
- package/cli/lib/schemas.cjs +6 -1
- package/cli/nuke.js +13 -8
- package/cli/postinstall.js +14 -4
- package/cli/rcode-slash-router.cjs +118 -0
- package/cli/uninstall.js +59 -1
- package/cli/update.js +10 -5
- package/dist/rcode.js +234 -230
- package/package.json +1 -1
- package/server/dashboard.js +26 -7
- package/server/lib/api.js +62 -4
- package/server/lib/html/client/agents-data.js +22 -18
- package/server/lib/html/client/app.js +3 -0
- package/server/lib/html/client/components/AgentCard.js +127 -0
- package/server/lib/html/client/components/App.js +104 -39
- package/server/lib/html/client/components/CommandPalette.js +133 -0
- package/server/lib/html/client/components/FileReader.js +116 -0
- package/server/lib/html/client/components/FilterChips.js +94 -0
- package/server/lib/html/client/components/NotifyCenter.js +117 -0
- package/server/lib/html/client/components/OrchPanel.js +80 -52
- package/server/lib/html/client/components/PhaseGraph.js +300 -0
- package/server/lib/html/client/components/RejectDialog.js +78 -0
- package/server/lib/html/client/components/RunnerPicker.js +190 -0
- package/server/lib/html/client/components/Sidebar.js +106 -61
- package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
- package/server/lib/html/client/components/TaskPipeline.js +83 -0
- package/server/lib/html/client/components/Topbar.js +86 -39
- package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
- package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
- package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
- package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
- package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
- package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
- package/server/lib/html/client/components/shared.js +47 -11
- package/server/lib/html/client/filter-state.js +72 -0
- package/server/lib/html/client/icons-client.js +7 -0
- package/server/lib/html/client/notify.js +75 -0
- package/server/lib/html/client/orchestrator.js +168 -41
- package/server/lib/html/client/preact.js +13 -8
- package/server/lib/html/client/store.js +70 -6
- package/server/lib/html/client/util.js +78 -0
- package/server/lib/html/client/vendor/htm.js +1 -0
- package/server/lib/html/client/vendor/preact-hooks.js +2 -0
- package/server/lib/html/client/vendor/preact.js +2 -0
- package/server/lib/html/client/views/AgentsView.js +144 -51
- package/server/lib/html/client/views/FilesView.js +20 -103
- package/server/lib/html/client/views/KanbanView.js +40 -21
- package/server/lib/html/client/views/MemoryView.js +26 -9
- package/server/lib/html/client/views/MilestonesView.js +4 -4
- package/server/lib/html/client/views/OrchestrationView.js +154 -19
- package/server/lib/html/client/views/OverviewView.js +47 -239
- package/server/lib/html/client/views/PhasesView.js +50 -6
- package/server/lib/html/client/views/RoadmapView.js +6 -3
- package/server/lib/html/client/views/SprintsView.js +50 -6
- package/server/lib/html/client/views/TasksView.js +4 -3
- package/server/lib/html/client.js +21 -4
- package/server/lib/html/css.js +2761 -8
- package/server/lib/html/icons.js +7 -0
- package/server/lib/html/shell.js +10 -3
- package/server/lib/scanner.js +376 -39
- package/server/orchestrator.js +329 -5
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { html, useState } from '../preact.js';
|
|
11
|
-
import { pctNum, chip as chipDesc, humanDate, pct } from '../util.js';
|
|
11
|
+
import { pctNum, chip as chipDesc, humanDate, pct, currentPhaseId } from '../util.js';
|
|
12
12
|
import {
|
|
13
|
-
|
|
13
|
+
isSessionRunning, runningInSprint, runningInPhase,
|
|
14
14
|
} from '../orchestrator.js';
|
|
15
|
+
import { getState } from '../store.js';
|
|
15
16
|
import { Icon } from '../icons-client.js';
|
|
17
|
+
import { TaskPipeline } from './TaskPipeline.js';
|
|
18
|
+
import { openRunnerPicker } from './RunnerPicker.js';
|
|
16
19
|
|
|
17
20
|
// ---- Toast helper (shared by CmdHint copy action and any view) ----
|
|
18
21
|
export function showToast(msg) {
|
|
@@ -23,6 +26,29 @@ export function showToast(msg) {
|
|
|
23
26
|
setTimeout(() => el.classList.remove('show'), 2000);
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
// ---- pressable ----
|
|
30
|
+
/**
|
|
31
|
+
* Spreadable props that make a clickable non-button element keyboard
|
|
32
|
+
* accessible: focusable, announced as a button, activated by Enter/Space.
|
|
33
|
+
* Usage: html`<div class="item item-clickable" ...${pressable(fn)}>…</div>`
|
|
34
|
+
*/
|
|
35
|
+
export function pressable(onActivate) {
|
|
36
|
+
return {
|
|
37
|
+
role: 'button',
|
|
38
|
+
tabindex: 0,
|
|
39
|
+
onClick: onActivate,
|
|
40
|
+
onKeyDown: (e) => {
|
|
41
|
+
// Ignore keydown bubbling up from nested interactive elements
|
|
42
|
+
// (e.g. a Run button inside a clickable card row).
|
|
43
|
+
if (e.target !== e.currentTarget) return;
|
|
44
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
onActivate(e);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
// ---- Chip ----
|
|
27
53
|
/**
|
|
28
54
|
* Status chip.
|
|
@@ -119,7 +145,7 @@ export function CmdHint({ cmd, desc }) {
|
|
|
119
145
|
});
|
|
120
146
|
}
|
|
121
147
|
return html`
|
|
122
|
-
<div class="cmd-hint-item"
|
|
148
|
+
<div class="cmd-hint-item" ...${pressable(handleClick)}>
|
|
123
149
|
<span class="cmd-text">${cmd}</span>
|
|
124
150
|
<span class="cmd-desc">${desc}</span>
|
|
125
151
|
<${Icon} name="copy" size=${14} cls="cmd-copy"/>
|
|
@@ -145,16 +171,22 @@ export function CmdHints({ hints }) {
|
|
|
145
171
|
|
|
146
172
|
// ---- RunBtn ----
|
|
147
173
|
/**
|
|
148
|
-
* Compact run button.
|
|
174
|
+
* Compact run button. Opens the runner/model picker popover anchored to the
|
|
175
|
+
* button; the picker launches the session via runAndOpenTerm.
|
|
149
176
|
* @param {{ storyId: string, cmd: string, label: string }} props
|
|
150
177
|
*/
|
|
151
178
|
export function RunBtn({ storyId, cmd, label }) {
|
|
179
|
+
// Plain read (not a subscription) — every parent view already re-renders
|
|
180
|
+
// on store changes, so the disabled state stays current with the 4s poll.
|
|
181
|
+
const down = getState().orchOnline === false;
|
|
152
182
|
function handleClick(e) {
|
|
153
183
|
e.stopPropagation();
|
|
154
|
-
|
|
184
|
+
openRunnerPicker(e.currentTarget, { kind: 'session', storyId, cmd, title: label });
|
|
155
185
|
}
|
|
156
186
|
return html`
|
|
157
|
-
<button class="card-run-btn"
|
|
187
|
+
<button class="card-run-btn" disabled=${down}
|
|
188
|
+
title=${down ? 'Orchestrator unreachable' : 'Run ' + label}
|
|
189
|
+
onClick=${handleClick}>
|
|
158
190
|
▶ Run
|
|
159
191
|
</button>
|
|
160
192
|
`;
|
|
@@ -180,12 +212,14 @@ export function PhaseCard({ phase: p, S }) {
|
|
|
180
212
|
const sps = p.sprints || [];
|
|
181
213
|
const stories = sps.flatMap(s => s.stories || []);
|
|
182
214
|
const done = stories.filter(t => t.status === 'done' || t.status === 'completed').length;
|
|
183
|
-
|
|
215
|
+
// currentPhase is the contract object (or legacy string) — compare by id.
|
|
216
|
+
const cpId = currentPhaseId(S && S.currentPhase);
|
|
217
|
+
const isCur = cpId !== '' && String(p.id) === cpId;
|
|
184
218
|
const running = runningInPhase(p);
|
|
185
219
|
const borderStyle = isCur ? 'border-left-color:var(--accent-amber)' : '';
|
|
186
220
|
return html`
|
|
187
221
|
<div class=${'item item-clickable'} style=${borderStyle}
|
|
188
|
-
|
|
222
|
+
...${pressable(() => { location.hash = 'phases/' + p.id; })}>
|
|
189
223
|
<div class="item-title">
|
|
190
224
|
${sps.length ? html`<${RunBtn} storyId=${'phase-' + p.id} cmd=${'/rcode-execute ' + p.id} label=${'Phase ' + p.id}/>` : null}
|
|
191
225
|
Phase ${p.id} — ${p.name}
|
|
@@ -232,7 +266,7 @@ export function SprintCard({ sprint: s, S }) {
|
|
|
232
266
|
: '';
|
|
233
267
|
return html`
|
|
234
268
|
<div class=${'item item-clickable' + (isCur ? ' sprint-current' : '')} style=${borderStyle}
|
|
235
|
-
|
|
269
|
+
...${pressable(() => { location.hash = 'sprints/' + s.id; })}>
|
|
236
270
|
<div class="item-title">
|
|
237
271
|
<${RunBtn} storyId=${'sprint-' + s.id} cmd=${'/rcode-execute-sprint ' + s.id} label=${'Sprint ' + s.id}/>
|
|
238
272
|
Sprint ${s.id} — ${s.goal || 'No goal'}
|
|
@@ -290,7 +324,8 @@ export function TaskCard({ task: t }) {
|
|
|
290
324
|
return html`
|
|
291
325
|
<div class="item item-clickable" data-status=${t.status || ''}
|
|
292
326
|
style=${done ? 'opacity:.65' : ''}
|
|
293
|
-
|
|
327
|
+
aria-expanded=${expanded}
|
|
328
|
+
...${pressable(() => setExpanded(e => !e))}>
|
|
294
329
|
<div class="item-title" style=${done ? 'text-decoration:line-through' : ''}>
|
|
295
330
|
${t.id && !done ? html`<${RunBtn} storyId=${t.id} cmd=${'/rcode-dev-story ' + t.id} label=${'Story ' + t.id}/>` : null}
|
|
296
331
|
${done ? '✓ ' : ''}${t.title}
|
|
@@ -302,7 +337,8 @@ export function TaskCard({ task: t }) {
|
|
|
302
337
|
${t.id ? html`<${Tag}>${t.id}</${Tag}>` : null}
|
|
303
338
|
${t.sprintId ? html`<${Tag}>Sprint ${t.sprintId}</${Tag}>` : null}
|
|
304
339
|
${t.phaseId ? html`<${Tag}>Phase ${t.phaseId}</${Tag}>` : null}
|
|
305
|
-
${t.id && running ? html`<span class="run-badge"
|
|
340
|
+
${t.id && running ? html`<span class="run-badge"><span class="live-dot"></span>running</span>` : null}
|
|
341
|
+
<${TaskPipeline} task=${t} running=${t.id && running}/>
|
|
306
342
|
</div>
|
|
307
343
|
${expanded ? html`
|
|
308
344
|
<div class="task-detail">
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* filter-state.js — URL hash query-string filter module.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the `?status=&milestone=&date=` filter query portion of
|
|
5
|
+
* location.hash ONLY. It never touches the `view` or `subId` path segments.
|
|
6
|
+
*
|
|
7
|
+
* Hash shape: `#view/subId?status=done&milestone=M3&date=2026-05`
|
|
8
|
+
* - The path segment (`view/subId`) is managed by App.js parseHash.
|
|
9
|
+
* - The query segment (`status=...`) is managed here.
|
|
10
|
+
*
|
|
11
|
+
* Recognised filter keys: `status`, `milestone`, `date`. All others are ignored.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const FILTER_KEYS = ['status', 'milestone', 'date'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse filter state from a raw hash string.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} hash — raw hash string (with or without leading `#`).
|
|
20
|
+
* @returns {{ status: string, milestone: string, date: string }} — each value
|
|
21
|
+
* is a string or `''` when absent. Never throws on malformed input.
|
|
22
|
+
*/
|
|
23
|
+
export function parseFilters(hash) {
|
|
24
|
+
const result = { status: '', milestone: '', date: '' };
|
|
25
|
+
try {
|
|
26
|
+
const raw = typeof hash === 'string' ? hash.replace(/^#/, '') : '';
|
|
27
|
+
const qIdx = raw.indexOf('?');
|
|
28
|
+
if (qIdx === -1) return result;
|
|
29
|
+
const queryStr = raw.slice(qIdx + 1);
|
|
30
|
+
const params = new URLSearchParams(queryStr);
|
|
31
|
+
for (const key of FILTER_KEYS) {
|
|
32
|
+
const val = params.get(key);
|
|
33
|
+
if (val !== null) result[key] = val;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// Malformed input — return all-empty object.
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Serialise a filter object into a query string.
|
|
43
|
+
*
|
|
44
|
+
* @param {{ status: string, milestone: string, date: string }} filters
|
|
45
|
+
* @returns {string} — query string WITHOUT a leading `?`. Empty string when no
|
|
46
|
+
* active filter. Keys are always appended in fixed order: status, milestone, date.
|
|
47
|
+
*/
|
|
48
|
+
export function serialiseFilters(filters) {
|
|
49
|
+
const params = new URLSearchParams();
|
|
50
|
+
for (const key of FILTER_KEYS) {
|
|
51
|
+
const val = filters?.[key];
|
|
52
|
+
if (typeof val === 'string' && val !== '') {
|
|
53
|
+
params.append(key, val);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return params.toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a full hash body from a view path and filter object.
|
|
61
|
+
*
|
|
62
|
+
* Used by FilterChips (34.2) to update `location.hash` without disturbing
|
|
63
|
+
* the view path segment.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} viewPath — view path segment, e.g. `phases` or `sprints/3`.
|
|
66
|
+
* @param {{ status: string, milestone: string, date: string }} filters
|
|
67
|
+
* @returns {string} — hash body: `viewPath` or `viewPath?query`.
|
|
68
|
+
*/
|
|
69
|
+
export function applyFilters(viewPath, filters) {
|
|
70
|
+
const query = serialiseFilters(filters);
|
|
71
|
+
return query ? `${viewPath}?${query}` : viewPath;
|
|
72
|
+
}
|
|
@@ -34,6 +34,7 @@ export const ICONS = {
|
|
|
34
34
|
minimize: '<line x1="5" y1="14" x2="19" y2="14"/>',
|
|
35
35
|
maximize: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
|
|
36
36
|
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
|
37
|
+
history: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
|
37
38
|
eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/>',
|
|
38
39
|
filePen: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7"/><polyline points="14 2 14 8 20 8"/><path d="M18.4 12.6a2 2 0 0 1 3 3L17 20l-4 1 1-4z"/>',
|
|
39
40
|
hourglass: '<path d="M5 22h14M5 2h14M17 22v-4.17a2 2 0 0 0-.59-1.42L12 12l-4.41 4.41A2 2 0 0 0 7 17.83V22M7 2v4.17a2 2 0 0 0 .59 1.42L12 12l4.41-4.41A2 2 0 0 0 17 6.17V2"/>',
|
|
@@ -54,6 +55,12 @@ export const ICONS = {
|
|
|
54
55
|
// Added in sprint 32.3 — App/Topbar theme toggle icons
|
|
55
56
|
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
|
|
56
57
|
sun: '<circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="7.05" y2="7.05"/><line x1="16.95" y1="16.95" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="7.05" y2="16.95"/><line x1="16.95" y1="7.05" x2="19.78" y2="4.22"/>',
|
|
58
|
+
|
|
59
|
+
// Added in sprint 36.1 — command palette search icon
|
|
60
|
+
search: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
|
61
|
+
|
|
62
|
+
// Blocked-session notifications — topbar bell
|
|
63
|
+
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
57
64
|
};
|
|
58
65
|
|
|
59
66
|
/**
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify.js — blocked-session notification tracker.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic, no DOM. The 4s session poll (orchestrator.js _poll) calls
|
|
5
|
+
* trackBlocked(sessions) after each tick. This module diffs the blocked set
|
|
6
|
+
* against the previous tick and writes store.blockedAlerts — the persistent
|
|
7
|
+
* clickable toasts rendered by components/NotifyCenter.js.
|
|
8
|
+
*
|
|
9
|
+
* Browser Notification API is used ONLY when permission is already granted;
|
|
10
|
+
* we never call Notification.requestPermission().
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getState, setState } from './store.js';
|
|
14
|
+
|
|
15
|
+
// storyIds that were blocked on the previous poll tick — transition detector.
|
|
16
|
+
let _prevBlocked = new Set();
|
|
17
|
+
|
|
18
|
+
/** True when the browser Notification API is usable without prompting. */
|
|
19
|
+
function notificationsGranted() {
|
|
20
|
+
return typeof window !== 'undefined'
|
|
21
|
+
&& 'Notification' in window
|
|
22
|
+
&& window.Notification.permission === 'granted';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Fire a system notification for a newly-blocked session (granted-only). */
|
|
26
|
+
function systemNotify(storyId) {
|
|
27
|
+
if (!notificationsGranted()) return;
|
|
28
|
+
try {
|
|
29
|
+
new window.Notification('Agent waiting for input', {
|
|
30
|
+
body: 'Session ' + storyId + ' is blocked on a question.',
|
|
31
|
+
tag: 'rcode-blocked-' + storyId, // dedupe: re-fires replace, not stack
|
|
32
|
+
});
|
|
33
|
+
} catch { /* notification constructor can throw in some embeds — ignore */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Diff the latest session list against the previous tick:
|
|
38
|
+
* - session newly blocked → append a persistent alert + system notification
|
|
39
|
+
* - session left blocked → drop its alert (answered / exited / stopped)
|
|
40
|
+
* Alerts: [{ storyId, cmd }] in store.blockedAlerts.
|
|
41
|
+
*/
|
|
42
|
+
export function trackBlocked(sessions) {
|
|
43
|
+
const nowBlocked = new Map();
|
|
44
|
+
for (const s of sessions || []) {
|
|
45
|
+
if (s.status === 'blocked') nowBlocked.set(s.storyId, s);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const alerts = (getState().blockedAlerts || [])
|
|
49
|
+
// Drop alerts for sessions that are no longer blocked.
|
|
50
|
+
.filter(a => nowBlocked.has(a.storyId));
|
|
51
|
+
|
|
52
|
+
let changed = alerts.length !== (getState().blockedAlerts || []).length;
|
|
53
|
+
|
|
54
|
+
for (const [storyId, s] of nowBlocked) {
|
|
55
|
+
if (_prevBlocked.has(storyId)) continue; // already known
|
|
56
|
+
if (alerts.some(a => a.storyId === storyId)) continue; // already alerted
|
|
57
|
+
alerts.push({ storyId, cmd: s.cmd || '' });
|
|
58
|
+
systemNotify(storyId);
|
|
59
|
+
changed = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_prevBlocked = new Set(nowBlocked.keys());
|
|
63
|
+
if (changed) setState({ blockedAlerts: alerts });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Dismiss one alert toast without touching the session. */
|
|
67
|
+
export function dismissBlockedAlert(storyId) {
|
|
68
|
+
const alerts = (getState().blockedAlerts || []).filter(a => a.storyId !== storyId);
|
|
69
|
+
setState({ blockedAlerts: alerts });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Sessions currently blocked (drives the topbar bell). */
|
|
73
|
+
export function blockedSessions() {
|
|
74
|
+
return (getState().activeSessions || []).filter(s => s.status === 'blocked');
|
|
75
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { getState, setState } from './store.js';
|
|
14
14
|
import { showToast } from './components/shared.js';
|
|
15
|
+
import { trackBlocked } from './notify.js';
|
|
15
16
|
|
|
16
17
|
export const ORCH_HTTP = 'http://localhost:7718';
|
|
17
18
|
export const ORCH_WS = 'ws://localhost:7718';
|
|
@@ -38,17 +39,43 @@ export function refreshOrchToken() {
|
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* POST /api/run — start a PTY session for storyId.
|
|
42
|
+
* opts = { runner?, model? } — which agent CLI / model to launch. Omitted →
|
|
43
|
+
* the server default (claude, no model flag). The server re-validates both.
|
|
41
44
|
* Returns the parsed JSON response (or throws on network error).
|
|
42
45
|
*/
|
|
43
|
-
export function runSession(storyId, cmd) {
|
|
44
|
-
const tok
|
|
46
|
+
export function runSession(storyId, cmd, opts) {
|
|
47
|
+
const tok = orchToken();
|
|
48
|
+
const body = { storyId, cmd };
|
|
49
|
+
if (opts && opts.runner) {
|
|
50
|
+
body.runner = opts.runner;
|
|
51
|
+
if (opts.model) body.model = opts.model;
|
|
52
|
+
}
|
|
45
53
|
return fetch(ORCH_HTTP + '/api/run', {
|
|
46
54
|
method: 'POST',
|
|
47
55
|
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
48
|
-
body: JSON.stringify(
|
|
56
|
+
body: JSON.stringify(body),
|
|
49
57
|
}).then(r => r.json());
|
|
50
58
|
}
|
|
51
59
|
|
|
60
|
+
/**
|
|
61
|
+
* GET /api/runners — detected agent CLIs: [{ id, label, available, models }].
|
|
62
|
+
* The list is fixed for the orchestrator's lifetime (detected once at boot),
|
|
63
|
+
* so the first successful response is cached; failures are not cached so a
|
|
64
|
+
* later open retries.
|
|
65
|
+
*/
|
|
66
|
+
let _runnersPromise = null;
|
|
67
|
+
export function fetchRunners() {
|
|
68
|
+
if (_runnersPromise) return _runnersPromise;
|
|
69
|
+
const tok = orchToken();
|
|
70
|
+
_runnersPromise = fetch(ORCH_HTTP + '/api/runners', {
|
|
71
|
+
headers: { 'Authorization': 'Bearer ' + tok },
|
|
72
|
+
})
|
|
73
|
+
.then(r => r.json())
|
|
74
|
+
.then(d => (d && d.runners) || [])
|
|
75
|
+
.catch(() => { _runnersPromise = null; return []; });
|
|
76
|
+
return _runnersPromise;
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
/**
|
|
53
80
|
* POST /api/stop — stop a running session.
|
|
54
81
|
*/
|
|
@@ -62,21 +89,95 @@ export function stopSession(storyId) {
|
|
|
62
89
|
}
|
|
63
90
|
|
|
64
91
|
/**
|
|
65
|
-
* GET /api/sessions —
|
|
92
|
+
* GET /api/sessions — resolve { ok, sessions }. ok=false means the
|
|
93
|
+
* orchestrator was unreachable (network failure / no token), which the
|
|
94
|
+
* session poll records as orchOnline so the UI can show a down state.
|
|
66
95
|
*/
|
|
67
|
-
|
|
96
|
+
function fetchSessionsWithStatus() {
|
|
68
97
|
const tok = orchToken();
|
|
69
|
-
if (!tok) return Promise.resolve([]);
|
|
98
|
+
if (!tok) return Promise.resolve({ ok: false, sessions: [] });
|
|
70
99
|
return fetch(ORCH_HTTP + '/api/sessions', {
|
|
71
100
|
headers: { 'Authorization': 'Bearer ' + tok },
|
|
72
101
|
})
|
|
102
|
+
.then(r => {
|
|
103
|
+
if (r.status === 401) { refreshOrchToken(); return { ok: true, sessions: [] }; }
|
|
104
|
+
return r.json().then(d => ({ ok: true, sessions: (d && d.sessions) || [] }));
|
|
105
|
+
})
|
|
106
|
+
.catch(() => ({ ok: false, sessions: [] }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* GET /api/sessions — return the sessions array (or [] on error).
|
|
111
|
+
*/
|
|
112
|
+
export function fetchSessions() {
|
|
113
|
+
return fetchSessionsWithStatus().then(r => r.sessions);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* GET /api/history — return the persisted run history array (or [] on error).
|
|
118
|
+
*/
|
|
119
|
+
export function fetchHistory() {
|
|
120
|
+
const tok = orchToken();
|
|
121
|
+
if (!tok) return Promise.resolve([]);
|
|
122
|
+
return fetch(ORCH_HTTP + '/api/history', { headers: { 'Authorization': 'Bearer ' + tok } })
|
|
73
123
|
.then(r => {
|
|
74
124
|
if (r.status === 401) { refreshOrchToken(); return []; }
|
|
75
|
-
return r.json().then(d => (d && d.
|
|
125
|
+
return r.json().then(d => (d && d.history) || []);
|
|
76
126
|
})
|
|
77
127
|
.catch(() => []);
|
|
78
128
|
}
|
|
79
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Merge live sessions with persisted history, keyed on storyId.
|
|
132
|
+
* Live session fields win for most properties; durationMs and endTime are
|
|
133
|
+
* field-aware: the live record may not have them yet (session still running),
|
|
134
|
+
* so fall back to the history value if the live field is null/undefined.
|
|
135
|
+
*/
|
|
136
|
+
export function mergeSessionsAndHistory(live, hist) {
|
|
137
|
+
const byId = new Map();
|
|
138
|
+
for (const h of hist || []) byId.set(h.storyId, { ...h, source: 'history' });
|
|
139
|
+
for (const s of live || []) {
|
|
140
|
+
const h = byId.get(s.storyId) || {};
|
|
141
|
+
byId.set(s.storyId, {
|
|
142
|
+
...h,
|
|
143
|
+
...s,
|
|
144
|
+
source: 'live',
|
|
145
|
+
durationMs: s.durationMs ?? h.durationMs,
|
|
146
|
+
endTime: s.endTime ?? h.endTime,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return [...byId.values()];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** True unless the last session poll found the orchestrator unreachable. */
|
|
153
|
+
export function isOrchOnline() {
|
|
154
|
+
return getState().orchOnline !== false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* POST /api/reject — record a structured rejection reason for storyId.
|
|
159
|
+
* phase is optional. Returns the parsed JSON response.
|
|
160
|
+
*/
|
|
161
|
+
export function submitRejection(storyId, reason, phase) {
|
|
162
|
+
const tok = orchToken();
|
|
163
|
+
return fetch(ORCH_HTTP + '/api/reject', {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({ storyId, reason, phase: phase || null }),
|
|
167
|
+
}).then(r => r.json());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* GET /api/rejections — return the persisted rejections array (or [] on error).
|
|
172
|
+
*/
|
|
173
|
+
export function fetchRejections() {
|
|
174
|
+
const tok = orchToken();
|
|
175
|
+
if (!tok) return Promise.resolve([]);
|
|
176
|
+
return fetch(ORCH_HTTP + '/api/rejections', { headers: { 'Authorization': 'Bearer ' + tok } })
|
|
177
|
+
.then(r => r.ok ? r.json().then(d => (d && d.rejections) || []) : [])
|
|
178
|
+
.catch(() => []);
|
|
179
|
+
}
|
|
180
|
+
|
|
80
181
|
/**
|
|
81
182
|
* POST /api/clean-sessions — remove ended sessions.
|
|
82
183
|
* olderThanDays = 0 removes all ended sessions; > 0 keeps recent ones.
|
|
@@ -102,10 +203,13 @@ export function activeSession(storyId) {
|
|
|
102
203
|
return (activeSessions || []).find(s => s.storyId === storyId) || null;
|
|
103
204
|
}
|
|
104
205
|
|
|
105
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* True when storyId has a live session. 'blocked' is a live PTY waiting for
|
|
208
|
+
* input (server-side classification of running), so it counts as running here.
|
|
209
|
+
*/
|
|
106
210
|
export function isSessionRunning(storyId) {
|
|
107
|
-
const
|
|
108
|
-
return !!(
|
|
211
|
+
const { runningByStory } = getState();
|
|
212
|
+
return !!(storyId && runningByStory && runningByStory[storyId]);
|
|
109
213
|
}
|
|
110
214
|
|
|
111
215
|
/** Count running sessions touching this sprint (sprint-level + its stories). */
|
|
@@ -122,16 +226,22 @@ export function runningInPhase(p) {
|
|
|
122
226
|
return n;
|
|
123
227
|
}
|
|
124
228
|
|
|
125
|
-
/** Total count of sessions
|
|
229
|
+
/** Total count of live sessions (running or blocked-on-input). */
|
|
126
230
|
export function runningTotal() {
|
|
127
231
|
const { activeSessions } = getState();
|
|
128
|
-
return (activeSessions || []).filter(s => s.status === 'running').length;
|
|
232
|
+
return (activeSessions || []).filter(s => s.status === 'running' || s.status === 'blocked').length;
|
|
129
233
|
}
|
|
130
234
|
|
|
131
235
|
// ── Session poll ──────────────────────────────────────────────────────────────
|
|
132
236
|
|
|
133
237
|
let _pollTimer = null;
|
|
134
238
|
|
|
239
|
+
// Serialized snapshot of the last committed activeSessions. The 4s poll
|
|
240
|
+
// always produces a NEW array identity, which defeats the store's
|
|
241
|
+
// reference-equality change check — so compare content here and only
|
|
242
|
+
// setState when the sessions actually changed.
|
|
243
|
+
let _lastSessionsJson = null;
|
|
244
|
+
|
|
135
245
|
/**
|
|
136
246
|
* Start polling /api/sessions every 4 s and writing activeSessions into the
|
|
137
247
|
* store. Components react via useStore(). Safe to call multiple times — only
|
|
@@ -149,9 +259,23 @@ export function stopSessionsPoll() {
|
|
|
149
259
|
}
|
|
150
260
|
|
|
151
261
|
function _poll() {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
262
|
+
Promise.all([fetchSessionsWithStatus(), fetchHistory(), fetchRejections()])
|
|
263
|
+
.then(([{ ok, sessions }, history, rejections]) => {
|
|
264
|
+
// Merge any recorded rejection onto its matching session by storyId.
|
|
265
|
+
const byId = {};
|
|
266
|
+
for (const r of rejections) byId[r.storyId] = r;
|
|
267
|
+
const merged = sessions.map(s => byId[s.storyId] ? { ...s, rejection: byId[s.storyId] } : s);
|
|
268
|
+
|
|
269
|
+
// Dedupe: skip the setState when neither the session list, the
|
|
270
|
+
// orchestrator-online flag, nor the history changed — avoids a
|
|
271
|
+
// full-app re-render per poll tick.
|
|
272
|
+
const json = JSON.stringify(merged) + '|' + ok + '|' + JSON.stringify(history);
|
|
273
|
+
if (json === _lastSessionsJson) return;
|
|
274
|
+
_lastSessionsJson = json;
|
|
275
|
+
setState({ activeSessions: merged, history, orchOnline: ok });
|
|
276
|
+
// Detect running→blocked transitions and raise persistent alerts.
|
|
277
|
+
trackBlocked(merged);
|
|
278
|
+
});
|
|
155
279
|
}
|
|
156
280
|
|
|
157
281
|
// ── runAndOpenTerm convenience ────────────────────────────────────────────────
|
|
@@ -163,8 +287,9 @@ function _poll() {
|
|
|
163
287
|
* @param {string} storyId
|
|
164
288
|
* @param {string} cmd
|
|
165
289
|
* @param {string} title
|
|
290
|
+
* @param {{ runner?: string, model?: string }} [opts] — agent CLI selection
|
|
166
291
|
*/
|
|
167
|
-
export function runAndOpenTerm(storyId, cmd, title) {
|
|
292
|
+
export function runAndOpenTerm(storyId, cmd, title, opts) {
|
|
168
293
|
// Open the panel immediately (it shows "connecting" while the session starts).
|
|
169
294
|
setState({
|
|
170
295
|
terminal: {
|
|
@@ -177,9 +302,19 @@ export function runAndOpenTerm(storyId, cmd, title) {
|
|
|
177
302
|
});
|
|
178
303
|
|
|
179
304
|
const tok = orchToken();
|
|
180
|
-
if (!tok) return;
|
|
305
|
+
if (!tok) { showToast('No orchestrator token — restart the dashboard'); return; }
|
|
181
306
|
|
|
182
|
-
runSession(storyId, cmd
|
|
307
|
+
runSession(storyId, cmd, opts)
|
|
308
|
+
.then(data => {
|
|
309
|
+
// 409 = already running (terminal reattaches); anything else is surfaced.
|
|
310
|
+
if (data && data.error && !data.error.includes('already running')) {
|
|
311
|
+
showToast('Run error: ' + data.error);
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
.catch(err => {
|
|
315
|
+
console.error('[orchestrator] session op failed:', err.message);
|
|
316
|
+
showToast('Could not reach orchestrator — session not started');
|
|
317
|
+
});
|
|
183
318
|
}
|
|
184
319
|
|
|
185
320
|
/**
|
|
@@ -205,15 +340,6 @@ export function openOrchPanel(storyId) {
|
|
|
205
340
|
setState({ orchPanel: { open: true, storyId } });
|
|
206
341
|
}
|
|
207
342
|
|
|
208
|
-
/**
|
|
209
|
-
* runStory — Kanban "Run" action. Moves card to in_progress visually then
|
|
210
|
-
* delegates to runAndOpenTerm.
|
|
211
|
-
*/
|
|
212
|
-
export function runStory(storyId) {
|
|
213
|
-
if (!storyId) return;
|
|
214
|
-
runAndOpenTerm(storyId, '/rcode-dev-story ' + storyId, storyId);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
343
|
/**
|
|
218
344
|
* stopStory — Kanban "Stop" action.
|
|
219
345
|
*/
|
|
@@ -228,18 +354,18 @@ export function stopStory(storyId) {
|
|
|
228
354
|
* Update both when adding a new command.
|
|
229
355
|
*/
|
|
230
356
|
export const ALLOWED_COMMANDS = [
|
|
231
|
-
{ cmd: '/rcode-init', label: 'init — initialise project workspace' },
|
|
232
|
-
{ cmd: '/rcode-
|
|
233
|
-
{ cmd: '/rcode-
|
|
234
|
-
{ cmd: '/rcode-
|
|
235
|
-
{ cmd: '/rcode-
|
|
236
|
-
{ cmd: '/rcode-
|
|
237
|
-
{ cmd: '/rcode-show', label: 'show — show current plan' },
|
|
238
|
-
{ cmd: '/rcode-list-plans', label: 'list-plans — list all sprint plans' },
|
|
239
|
-
{ cmd: '/rcode-
|
|
240
|
-
{ cmd: '/rcode-
|
|
241
|
-
{ cmd: '/rcode-
|
|
242
|
-
{ cmd: '/rcode-
|
|
357
|
+
{ cmd: '/rcode-init', label: 'init — initialise project workspace', category: 'Project' },
|
|
358
|
+
{ cmd: '/rcode-config', label: 'config — show rcode config', category: 'Project' },
|
|
359
|
+
{ cmd: '/rcode-status', label: 'status — phase / sprint status', category: 'Status' },
|
|
360
|
+
{ cmd: '/rcode-progress', label: 'progress — milestone progress', category: 'Status' },
|
|
361
|
+
{ cmd: '/rcode-sprint-status', label: 'sprint-status — sprint execution status',category: 'Status' },
|
|
362
|
+
{ cmd: '/rcode-stats', label: 'stats — project statistics', category: 'Status' },
|
|
363
|
+
{ cmd: '/rcode-show', label: 'show — show current plan', category: 'Planning' },
|
|
364
|
+
{ cmd: '/rcode-list-plans', label: 'list-plans — list all sprint plans', category: 'Planning' },
|
|
365
|
+
{ cmd: '/rcode-next', label: 'next — suggest next action', category: 'Planning' },
|
|
366
|
+
{ cmd: '/rcode-help', label: 'help — command reference', category: 'Inspect' },
|
|
367
|
+
{ cmd: '/rcode-health', label: 'health — repo health check', category: 'Inspect' },
|
|
368
|
+
{ cmd: '/rcode-diff', label: 'diff — diff since last checkpoint', category: 'Inspect' },
|
|
243
369
|
];
|
|
244
370
|
|
|
245
371
|
/**
|
|
@@ -255,8 +381,9 @@ export const ALLOWED_COMMANDS = [
|
|
|
255
381
|
* - network failure → showToast('Could not reach orchestrator')
|
|
256
382
|
*
|
|
257
383
|
* @param {string} cmd Must be one of ALLOWED_COMMANDS[*].cmd.
|
|
384
|
+
* @param {{ runner?: string, model?: string }} [opts] — agent CLI selection
|
|
258
385
|
*/
|
|
259
|
-
export function runCommandFromUI(cmd) {
|
|
386
|
+
export function runCommandFromUI(cmd, opts) {
|
|
260
387
|
if (!cmd) return;
|
|
261
388
|
const slug = cmd.replace(/^\//, '').replace(/\//g, '-');
|
|
262
389
|
const storyId = 'cmd-' + slug;
|
|
@@ -269,7 +396,7 @@ export function runCommandFromUI(cmd) {
|
|
|
269
396
|
const tok = orchToken();
|
|
270
397
|
if (!tok) { showToast('No orchestrator token — restart the dashboard'); return; }
|
|
271
398
|
|
|
272
|
-
runSession(storyId, cmd)
|
|
399
|
+
runSession(storyId, cmd, opts)
|
|
273
400
|
.then(data => {
|
|
274
401
|
// 409 = already running (not an error — terminal is already attached).
|
|
275
402
|
if (data && data.error && !data.error.includes('already running')) {
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Preact + htm ESM runtime — single dependency surface.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Vendored locally under vendor/ so the dashboard works offline /
|
|
5
|
+
* air-gapped / on CI screens with no internet (no esm.sh dependency).
|
|
6
|
+
* Every other client module imports from this file so version bumps
|
|
7
|
+
* happen in one place: replace the vendor/ files and update the pins.
|
|
6
8
|
*
|
|
7
|
-
* Pinned versions
|
|
8
|
-
* preact 10.24.3
|
|
9
|
-
* htm 3.1.1
|
|
9
|
+
* Pinned vendored versions:
|
|
10
|
+
* preact 10.24.3 (vendor/preact.js, vendor/preact-hooks.js)
|
|
11
|
+
* htm 3.1.1 (vendor/htm.js)
|
|
12
|
+
*
|
|
13
|
+
* vendor/preact-hooks.js has its bare `from "preact"` import rewritten
|
|
14
|
+
* to `from "./preact.js"` so it resolves without an import map.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
|
-
import { h, render, Fragment } from '
|
|
17
|
+
import { h, render, Fragment } from './vendor/preact.js';
|
|
13
18
|
import {
|
|
14
19
|
useState,
|
|
15
20
|
useEffect,
|
|
@@ -17,8 +22,8 @@ import {
|
|
|
17
22
|
useMemo,
|
|
18
23
|
useCallback,
|
|
19
24
|
useReducer,
|
|
20
|
-
} from '
|
|
21
|
-
import htmLib from '
|
|
25
|
+
} from './vendor/preact-hooks.js';
|
|
26
|
+
import htmLib from './vendor/htm.js';
|
|
22
27
|
|
|
23
28
|
// htm bound to Preact's h — use as a tagged template literal: html`<div>...</div>`
|
|
24
29
|
export const html = htmLib.bind(h);
|