crewforth 3.2.0-rc.1 → 3.2.0-rc.10
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/CHANGELOG.md +190 -5
- package/README.md +2 -0
- package/VERSION +1 -1
- package/adopt.sh +55 -6
- package/bin/cli.js +3 -2
- package/kit/CLAUDE.md +6 -6
- package/kit/agents/crew-commit-agent.md +6 -4
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/eval/doctor.sh +37 -3
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/lib/crew-map/narrative.mjs +42 -8
- package/kit/eval/lib/crew-map/project.mjs +3 -2
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +56 -4
- package/kit/eval/lib/crew-map/stacks.mjs +114 -8
- package/kit/eval/lib/crew-map/surface.mjs +81 -3
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +14 -4
- package/kit/eval/lib/statusline.sh +4 -19
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +424 -26
- package/kit/hooks/agent-outcome.sh +38 -9
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +163 -20
- package/kit/hooks/guard-bash.sh +69 -36
- package/kit/hooks/guard-commit-scan.sh +10 -2
- package/kit/hooks/guard-write.sh +3 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +74 -5
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/settings.json +18 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-map/SKILL.md +16 -5
- package/kit/skills/crew-review/SKILL.md +2 -1
- package/kit/skills/crew-ship/SKILL.md +3 -1
- package/kit/studio/README.md +21 -10
- package/kit/studio/server/index.js +85 -22
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +104 -18
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/web/app.js +79 -11
- package/kit/studio/web/canvas.js +14 -2
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +3 -2
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +72 -3
- package/kit/studio/web/map.js +103 -10
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +40 -0
- package/kit/studio/web/tokens.css +6 -0
- package/package.json +1 -1
- package/start.sh +14 -1
package/kit/studio/web/app.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
summaryChips, sessionStatus, sessionName, sessionSub, ago, matchesSession, highlight, badgeFor,
|
|
15
15
|
liveSessions, machines, seenAgo, isFirstRun,
|
|
16
16
|
} from './nav.js';
|
|
17
|
-
import { liveness, offlineNote } from './liveness.js';
|
|
17
|
+
import { liveness, offlineNote, staleServerNote } from './liveness.js';
|
|
18
18
|
import { attention, settle, AUTO } from './graph-plan.js';
|
|
19
19
|
import { summaryRows, usageLine, costNote } from './usage.js';
|
|
20
20
|
import { ServerClock, queue, settled, terminalWait, OUTCOME_WORD } from './approvals.js';
|
|
@@ -29,6 +29,7 @@ import { cardOf, cardLines, workSteps, outsideRouting } from './models-plan.js';
|
|
|
29
29
|
import { fitLevel, runsOver } from './toolbar-fit.js';
|
|
30
30
|
import { roleName, shownType, modelFamily } from './names.js';
|
|
31
31
|
import { shortcutOf } from './keys.js';
|
|
32
|
+
import { qrSvg } from './qr.js';
|
|
32
33
|
import { SESSION_MODES } from './convo.js';
|
|
33
34
|
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
34
35
|
|
|
@@ -113,12 +114,37 @@ const getJson = async (p) => {
|
|
|
113
114
|
heard.failed = true;
|
|
114
115
|
throw e;
|
|
115
116
|
}
|
|
116
|
-
if (!r.ok) throw
|
|
117
|
+
if (!r.ok) throw await statusError(r);
|
|
117
118
|
const body = await r.json();
|
|
118
119
|
heardNow();
|
|
119
120
|
return body;
|
|
120
121
|
};
|
|
121
122
|
|
|
123
|
+
// What the server said about this page's link: a viewer whose link hides transcripts is sent no transcript text.
|
|
124
|
+
let transcriptsHidden = false;
|
|
125
|
+
|
|
126
|
+
/** The error for an answer with an error status. A viewer link whose eight hours are up covers the page, once. */
|
|
127
|
+
async function statusError(r) {
|
|
128
|
+
if (r.status === 401) {
|
|
129
|
+
let b = null; try { b = await r.json(); } catch { /* not JSON */ }
|
|
130
|
+
if (b?.expired) showExpired();
|
|
131
|
+
}
|
|
132
|
+
if (r.status === 403 && transcriptsHidden) return new Error('Transcripts are hidden on this link');
|
|
133
|
+
return new Error(`HTTP ${r.status}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function showExpired() {
|
|
137
|
+
if (document.getElementById('expired')) return;
|
|
138
|
+
const box = document.createElement('div');
|
|
139
|
+
box.id = 'expired';
|
|
140
|
+
box.className = 'expired';
|
|
141
|
+
box.setAttribute('role', 'alert');
|
|
142
|
+
const h = document.createElement('h1'); h.textContent = 'Session expired';
|
|
143
|
+
const p = document.createElement('p'); p.textContent = 'Viewer links last eight hours. Ask for a new one on the machine running Studio.';
|
|
144
|
+
box.append(h, p);
|
|
145
|
+
document.body.append(box);
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
/* ---------------------------------------------------------------- theme */
|
|
123
149
|
|
|
124
150
|
try { migrateStorage(localStorage); } catch { /* blocked storage */ }
|
|
@@ -715,7 +741,8 @@ async function loadMap(force = false) {
|
|
|
715
741
|
mapBusy = true;
|
|
716
742
|
let data;
|
|
717
743
|
const q = `session=${encodeURIComponent(asked)}${mapCompare ? `&compare=${encodeURIComponent(mapCompare)}` : ''}`;
|
|
718
|
-
|
|
744
|
+
// A server older than this page has no Map route: say that, not its 404.
|
|
745
|
+
try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: restartNote ?? e.message, others: [] }; }
|
|
719
746
|
finally { mapBusy = false; }
|
|
720
747
|
if (asked !== current) return;
|
|
721
748
|
mapFor = asked;
|
|
@@ -2057,6 +2084,9 @@ let handed = null; // the link of the viewer just added: the server s
|
|
|
2057
2084
|
async function pollRemote() {
|
|
2058
2085
|
if (role !== 'owner') return;
|
|
2059
2086
|
try { remote = await getJson('/api/remote'); } catch { return; }
|
|
2087
|
+
// The link shown once leaves with its viewer: revoked from elsewhere (the API, another tab) or past its eight
|
|
2088
|
+
// hours, it no longer opens anything, and a QR code left on screen would say it does.
|
|
2089
|
+
if (handed && !(remote.viewers ?? []).some((v) => v.id === handed.id)) handed = null;
|
|
2060
2090
|
paintMachines();
|
|
2061
2091
|
}
|
|
2062
2092
|
|
|
@@ -2073,16 +2103,25 @@ async function remoteWrite(method, path, body) {
|
|
|
2073
2103
|
}
|
|
2074
2104
|
}
|
|
2075
2105
|
|
|
2106
|
+
/** A time today or tomorrow as the clock on the wall says it, e.g. 23:40. */
|
|
2107
|
+
const clockTime = (ms) => (Number.isFinite(ms) ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—');
|
|
2108
|
+
|
|
2109
|
+
let showTranscripts = false; // the owner's choice for the next viewer: transcripts stay hidden unless ticked
|
|
2110
|
+
|
|
2076
2111
|
async function addViewer(button) {
|
|
2077
2112
|
button.disabled = true;
|
|
2078
|
-
const res = await remoteWrite('POST', '/api/remote', {});
|
|
2113
|
+
const res = await remoteWrite('POST', '/api/remote', { transcripts: showTranscripts });
|
|
2079
2114
|
if (!res.ok) {
|
|
2080
2115
|
toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
|
|
2081
2116
|
button.disabled = false;
|
|
2082
2117
|
return;
|
|
2083
2118
|
}
|
|
2084
2119
|
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2085
|
-
|
|
2120
|
+
// The Wi-Fi link when the server listens on one; this machine's own address only when it found no Wi-Fi address.
|
|
2121
|
+
const wifi = res.lanUrls?.[0] ?? null;
|
|
2122
|
+
handed = { id: res.viewer.id, label: res.viewer.label, wifi, expiresAt: res.viewer.expiresAt, transcripts: res.viewer.transcripts,
|
|
2123
|
+
url: wifi ?? `${location.origin}/?token=${encodeURIComponent(res.viewer.token)}`, lanErrors: res.lan?.errors ?? [] };
|
|
2124
|
+
showTranscripts = false;
|
|
2086
2125
|
paintMachines();
|
|
2087
2126
|
}
|
|
2088
2127
|
|
|
@@ -2113,6 +2152,7 @@ function viewerRow(v) {
|
|
|
2113
2152
|
? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
|
|
2114
2153
|
: 'link not opened yet';
|
|
2115
2154
|
row.append(node('div', 'sub srow-sub', seen));
|
|
2155
|
+
row.append(node('div', 'sub srow-sub', `until ${clockTime(v.expiresAt)} · transcripts ${v.transcripts ? 'shown' : 'hidden'}${v.addresses?.length ? ` · from ${v.addresses.join(', ')}` : ''}`));
|
|
2116
2156
|
return row;
|
|
2117
2157
|
}
|
|
2118
2158
|
|
|
@@ -2130,9 +2170,24 @@ function remoteSection() {
|
|
|
2130
2170
|
head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
|
|
2131
2171
|
box.append(head);
|
|
2132
2172
|
|
|
2173
|
+
// Transcripts stay hidden on a new link unless this is ticked before Add viewer.
|
|
2174
|
+
const opt = node('label', 'rv-opt');
|
|
2175
|
+
const tick = document.createElement('input');
|
|
2176
|
+
tick.type = 'checkbox'; tick.checked = showTranscripts;
|
|
2177
|
+
tick.addEventListener('change', () => { showTranscripts = tick.checked; });
|
|
2178
|
+
opt.append(tick, document.createTextNode(' Show transcripts on the next link'));
|
|
2179
|
+
box.append(opt);
|
|
2180
|
+
|
|
2133
2181
|
if (handed) {
|
|
2134
2182
|
const give = node('div', 'rv-give');
|
|
2135
|
-
give.append(node('div', 'sub rv-say',
|
|
2183
|
+
give.append(node('div', 'sub rv-say', handed.wifi
|
|
2184
|
+
? `Link for ${handed.label}, shown once. Scan it with a phone on the same Wi-Fi. It lasts until ${clockTime(handed.expiresAt)}; transcripts ${handed.transcripts ? 'shown' : 'hidden'}.`
|
|
2185
|
+
: `Link for ${handed.label}, shown once. No Wi-Fi address was found${handed.lanErrors.length ? ` (${handed.lanErrors.join('; ')})` : ''}, so it opens on this machine only.`));
|
|
2186
|
+
if (handed.wifi) {
|
|
2187
|
+
const code = node('div', 'rv-qr');
|
|
2188
|
+
code.innerHTML = qrSvg(handed.wifi, { px: 3 });
|
|
2189
|
+
give.append(code);
|
|
2190
|
+
}
|
|
2136
2191
|
const url = node('code', 'rv-url', handed.url);
|
|
2137
2192
|
const copy = node('button', 'btn sm', 'Copy link');
|
|
2138
2193
|
copy.type = 'button';
|
|
@@ -2149,7 +2204,7 @@ function remoteSection() {
|
|
|
2149
2204
|
for (const v of list) box.append(viewerRow(v));
|
|
2150
2205
|
if (!list.length) {
|
|
2151
2206
|
box.append(node('div', 'sub rv-say',
|
|
2152
|
-
'A viewer
|
|
2207
|
+
'A viewer watches this panel from a phone on the same Wi-Fi and changes nothing. Its link lasts eight hours. With no viewer, the server listens on 127.0.0.1 only.'));
|
|
2153
2208
|
}
|
|
2154
2209
|
return box;
|
|
2155
2210
|
}
|
|
@@ -2635,7 +2690,9 @@ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnima
|
|
|
2635
2690
|
// They survive a page reload as long as the server does.
|
|
2636
2691
|
let ownedBusy = false;
|
|
2637
2692
|
async function pollOwned() {
|
|
2638
|
-
|
|
2693
|
+
// A link that hides transcripts is refused the sessions the panel runs (their conversation is all transcript):
|
|
2694
|
+
// asking every few seconds would only fill the machine's log with the same refusal.
|
|
2695
|
+
if (ownedBusy || transcriptsHidden) return;
|
|
2639
2696
|
ownedBusy = true;
|
|
2640
2697
|
try {
|
|
2641
2698
|
const r = await getJson('/api/owned');
|
|
@@ -2658,6 +2715,7 @@ chat.onPermissions = () => pollOwned();
|
|
|
2658
2715
|
view is being shown in a window too narrow for it. Offline is said first. */
|
|
2659
2716
|
|
|
2660
2717
|
let nextPollAt = null;
|
|
2718
|
+
let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
|
|
2661
2719
|
const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
|
|
2662
2720
|
stageNote.button.type = 'button';
|
|
2663
2721
|
stageNote.button.addEventListener('click', () => retryNow());
|
|
@@ -2666,11 +2724,12 @@ el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), s
|
|
|
2666
2724
|
function paintStageNote() {
|
|
2667
2725
|
const l = liveness(Date.now(), heard.okAt, heard.failed);
|
|
2668
2726
|
const off = offlineNote(l.state, heard.okAt, nextPollAt, Date.now());
|
|
2669
|
-
|
|
2727
|
+
// Offline first, then a server older than this page, then a window too narrow for the view.
|
|
2728
|
+
const warn = restartNote ?? narrowWarning(view, phoneBand.matches);
|
|
2670
2729
|
// The last picture stays on screen while the server is away, and is drawn as old.
|
|
2671
2730
|
el.stage.dataset.offline = String(Boolean(off));
|
|
2672
2731
|
el.stageNote.hidden = !off && !warn;
|
|
2673
|
-
el.stageNote.dataset.kind = off ? 'offline' : 'narrow';
|
|
2732
|
+
el.stageNote.dataset.kind = off ? 'offline' : restartNote ? 'restart' : 'narrow';
|
|
2674
2733
|
stageNote.text.textContent = off ? off.text : (warn ?? '');
|
|
2675
2734
|
stageNote.retry.textContent = off ? off.retry : '';
|
|
2676
2735
|
stageNote.retry.hidden = !off;
|
|
@@ -2815,9 +2874,18 @@ pollFleet(); pollSessions(); pollOwned();
|
|
|
2815
2874
|
// Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
|
|
2816
2875
|
// would refuse it either way.
|
|
2817
2876
|
getJson('/api/health').then((h) => {
|
|
2877
|
+
restartNote = staleServerNote(h);
|
|
2878
|
+
// A Map read before this answer showed the old server's 404; read it again so it says why.
|
|
2879
|
+
if (restartNote) { paintStageNote(); if (view === 'map') loadMap(true); }
|
|
2818
2880
|
role = h.role === 'viewer' ? 'viewer' : 'owner';
|
|
2881
|
+
transcriptsHidden = role === 'viewer' && h.transcripts === false;
|
|
2819
2882
|
document.body.dataset.role = role;
|
|
2820
|
-
document.getElementById('role')
|
|
2883
|
+
const badge = document.getElementById('role');
|
|
2884
|
+
badge.hidden = role !== 'viewer';
|
|
2885
|
+
if (transcriptsHidden) {
|
|
2886
|
+
badge.textContent = 'Read-only · transcripts hidden';
|
|
2887
|
+
badge.title = 'This link reads the panel and changes nothing; no transcript text is sent to it';
|
|
2888
|
+
}
|
|
2821
2889
|
if (role === 'owner') pollRemote(); else paintMachines();
|
|
2822
2890
|
}).catch(() => { /* the Live indicator says the server is unreachable */ });
|
|
2823
2891
|
setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
|
package/kit/studio/web/canvas.js
CHANGED
|
@@ -717,7 +717,10 @@ export class Canvas {
|
|
|
717
717
|
// ran out of the card.
|
|
718
718
|
p.bg = mk('div', 'cv-sub cv-indent cv-bg');
|
|
719
719
|
p.bg.hidden = true;
|
|
720
|
-
|
|
720
|
+
// Agent calls the model gate refused, which started no agent: a line of their own, the gate's words on hover.
|
|
721
|
+
p.refused = mk('div', 'cv-sub cv-indent cv-refused');
|
|
722
|
+
p.refused.hidden = true;
|
|
723
|
+
el.append(row, p.name, p.sub, p.bg, p.refused);
|
|
721
724
|
} else if (it.kind === 'group') {
|
|
722
725
|
el.classList.add('cv-group');
|
|
723
726
|
p.tile = mk('span', 'cv-tile');
|
|
@@ -837,12 +840,21 @@ export class Canvas {
|
|
|
837
840
|
p.name.title = p.name.textContent;
|
|
838
841
|
const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
|
|
839
842
|
if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
|
|
843
|
+
if (n.context?.toolShare != null) bits.push(`${n.context.toolShare}% tool output`);
|
|
840
844
|
p.sub.textContent = bits.join(' · ');
|
|
841
|
-
|
|
845
|
+
// The share is of the text since the last compaction, in characters; the compactions are the transcript's own.
|
|
846
|
+
const lc = n.context?.lastCompact;
|
|
847
|
+
p.sub.title = [p.sub.textContent,
|
|
848
|
+
n.context?.toolShare != null ? `${n.context.toolShare}% of the text in context since the last compaction is tool output (characters, not tokens)` : null,
|
|
849
|
+
n.context?.compactions ? `compacted ${n.context.compactions}×${lc?.before != null ? `, last ${lc.trigger ?? ''} ${fmtTokens(lc.before)} → ${fmtTokens(lc.after)}` : ''}` : null].filter(Boolean).join('\n');
|
|
842
850
|
// What the session has running that is not an agent: commands sent to the background.
|
|
843
851
|
p.bg.hidden = !bg;
|
|
844
852
|
p.bg.textContent = bg ? `${bg} in background` : '';
|
|
845
853
|
p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
|
|
854
|
+
const refused = n.refusedCalls ?? [];
|
|
855
|
+
p.refused.hidden = !refused.length;
|
|
856
|
+
p.refused.textContent = refused.length ? `${refused.length} refused by the model gate` : '';
|
|
857
|
+
p.refused.title = refused.map((r) => `${r.agent ?? 'an agent'}${r.model ? ` (${r.model})` : ''}: ${r.text}`).join('\n');
|
|
846
858
|
el.title = 'Click to inspect this session';
|
|
847
859
|
el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
|
|
848
860
|
}
|
package/kit/studio/web/chat.js
CHANGED
|
@@ -48,10 +48,76 @@ export function quickReplies(text) {
|
|
|
48
48
|
return (opts.length >= 2 && opts.length <= 5 && /\?/.test(text)) ? opts : [];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/* ============================================== every open conversation === */
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One connection for every open conversation. A pane per EventSource reached the browser's six connections to a
|
|
55
|
+
* host at five panes beside the page's own stream, and from there a message sent and every request the page made
|
|
56
|
+
* waited for a pane to close (measured on Windows). The panes register here; the stream is opened again, with
|
|
57
|
+
* each session's last event number, whenever the set changes or the connection drops, so nothing is replayed
|
|
58
|
+
* twice and nothing is missed.
|
|
59
|
+
*/
|
|
60
|
+
export class OwnedStream {
|
|
61
|
+
constructor(api, { EventSourceImpl = globalThis.EventSource, retryMs = 1500 } = {}) {
|
|
62
|
+
this.api = api;
|
|
63
|
+
this.ES = EventSourceImpl;
|
|
64
|
+
this.retryMs = retryMs;
|
|
65
|
+
this.panes = new Map(); // session id -> pane
|
|
66
|
+
this.seq = new Map(); // session id -> the last event number taken
|
|
67
|
+
this.source = null;
|
|
68
|
+
this.timer = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
attach(pane) { this.panes.set(pane.id, pane); this.#soon(0); }
|
|
72
|
+
|
|
73
|
+
detach(pane) {
|
|
74
|
+
if (this.panes.get(pane.id) !== pane) return;
|
|
75
|
+
this.panes.delete(pane.id); this.seq.delete(pane.id); this.#soon(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The URL the stream is opened at now: what a reconnect resumes from. */
|
|
79
|
+
url() {
|
|
80
|
+
const ids = [...this.panes.keys()];
|
|
81
|
+
const after = ids.map((id) => `${id}:${this.seq.get(id) ?? 0}`).join(',');
|
|
82
|
+
return this.api(`/api/owned-events?ids=${encodeURIComponent(ids.join(','))}&after=${encodeURIComponent(after)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Panes opened together (a page restoring its tabs) cost one reconnect, not one each.
|
|
86
|
+
#soon(ms) { clearTimeout(this.timer); this.timer = setTimeout(() => this.#open(), ms); }
|
|
87
|
+
|
|
88
|
+
#open() {
|
|
89
|
+
if (this.source) { this.source.close(); this.source = null; }
|
|
90
|
+
if (!this.panes.size) return;
|
|
91
|
+
const src = new this.ES(this.url());
|
|
92
|
+
const data = (e) => { try { return JSON.parse(e.data); } catch { return null; } };
|
|
93
|
+
src.addEventListener('state', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.takeState(d.session); });
|
|
94
|
+
src.addEventListener('event', (e) => {
|
|
95
|
+
const d = data(e); const pane = d && this.panes.get(d.id);
|
|
96
|
+
if (!pane || !d.ev) return;
|
|
97
|
+
if (Number.isFinite(d.ev.seq)) {
|
|
98
|
+
if (d.ev.seq <= (this.seq.get(d.id) ?? 0)) return; // already taken before a reconnect
|
|
99
|
+
this.seq.set(d.id, d.ev.seq);
|
|
100
|
+
}
|
|
101
|
+
pane.takeEvent(d.ev);
|
|
102
|
+
});
|
|
103
|
+
src.addEventListener('gone', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.streamLost(); });
|
|
104
|
+
// The browser would reconnect at the same URL, from the same event numbers, and replay what was taken since.
|
|
105
|
+
// So the stream is closed and opened again here, from where each session is now.
|
|
106
|
+
src.onerror = () => {
|
|
107
|
+
if (this.source !== src) return;
|
|
108
|
+
for (const pane of this.panes.values()) pane.streamLost();
|
|
109
|
+
this.#soon(this.retryMs);
|
|
110
|
+
};
|
|
111
|
+
this.source = src;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
close() { clearTimeout(this.timer); if (this.source) { this.source.close(); this.source = null; } this.panes.clear(); this.seq.clear(); }
|
|
115
|
+
}
|
|
116
|
+
|
|
51
117
|
/* ========================================================== one session === */
|
|
52
118
|
|
|
53
119
|
class Pane {
|
|
54
|
-
constructor(session, { api, headers, onChange, onPermissions, hooks = {}, readOnly = false }) {
|
|
120
|
+
constructor(session, { api, headers, onChange, onPermissions, hub = null, hooks = {}, readOnly = false }) {
|
|
55
121
|
this.api = api;
|
|
56
122
|
this.headers = headers;
|
|
57
123
|
this.onChange = onChange;
|
|
@@ -62,7 +128,8 @@ class Pane {
|
|
|
62
128
|
this.readOnly = readOnly;
|
|
63
129
|
this.session = session;
|
|
64
130
|
this.id = session.sessionId;
|
|
65
|
-
this.
|
|
131
|
+
this.hub = hub;
|
|
132
|
+
this.connected = false;
|
|
66
133
|
this.messages = [];
|
|
67
134
|
this.streaming = null;
|
|
68
135
|
this.permissions = [];
|
|
@@ -141,6 +208,7 @@ class Pane {
|
|
|
141
208
|
this.note(`History not read: ${e.message}`, 'bad');
|
|
142
209
|
return;
|
|
143
210
|
}
|
|
211
|
+
if (conv?.transcriptsHidden) { this.note('Transcripts are hidden on this link', 'muted'); return; }
|
|
144
212
|
if (conv?.measured === false) { this.note(`History not measured — ${conv.reason}`, 'bad'); return; }
|
|
145
213
|
|
|
146
214
|
const frag = document.createDocumentFragment();
|
|
@@ -163,25 +231,22 @@ class Pane {
|
|
|
163
231
|
}
|
|
164
232
|
|
|
165
233
|
connect() {
|
|
166
|
-
if (this.
|
|
167
|
-
this.
|
|
168
|
-
this.
|
|
169
|
-
try { this.session = JSON.parse(e.data); } catch { return; }
|
|
170
|
-
this.paintState();
|
|
171
|
-
this.onChange(this);
|
|
172
|
-
});
|
|
173
|
-
this.source.addEventListener('event', (e) => {
|
|
174
|
-
let ev;
|
|
175
|
-
try { ev = JSON.parse(e.data); } catch { return; }
|
|
176
|
-
this.absorb(ev.rec);
|
|
177
|
-
});
|
|
178
|
-
this.source.onerror = () => { this.streamBroken = true; this.onChange(this); };
|
|
234
|
+
if (this.connected || !this.hub) return;
|
|
235
|
+
this.connected = true;
|
|
236
|
+
this.hub.attach(this);
|
|
179
237
|
}
|
|
180
238
|
|
|
181
239
|
disconnect() {
|
|
182
|
-
if (this.
|
|
240
|
+
if (!this.connected) return;
|
|
241
|
+
this.connected = false;
|
|
242
|
+
this.hub.detach(this);
|
|
183
243
|
}
|
|
184
244
|
|
|
245
|
+
// What the shared stream hands this pane.
|
|
246
|
+
takeState(session) { if (!session) return; this.session = session; this.streamBroken = false; this.paintState(); this.onChange(this); }
|
|
247
|
+
takeEvent(ev) { this.streamBroken = false; this.absorb(ev.rec); }
|
|
248
|
+
streamLost() { this.streamBroken = true; this.onChange(this); }
|
|
249
|
+
|
|
185
250
|
/* --------------------------------------------------------- ingestion */
|
|
186
251
|
|
|
187
252
|
absorb(rec) {
|
|
@@ -495,9 +560,12 @@ class Pane {
|
|
|
495
560
|
cont.type = 'button';
|
|
496
561
|
cont.addEventListener('click', () => this.hooks.onContinue?.(this, cont));
|
|
497
562
|
const contRow = el('div', 'chat-ro-row');
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
563
|
+
// Said before the press, from the server's own check (the one Continue here acts on): the same session, or a copy.
|
|
564
|
+
// Until the server answers, and if it cannot: the sentence true of both outcomes.
|
|
565
|
+
const FALLBACK = 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
|
|
566
|
+
+ 'the original does not see this, and messages here never reach your terminal.';
|
|
567
|
+
const contText = el('span', 'sub', FALLBACK);
|
|
568
|
+
contRow.append(cont, contText);
|
|
501
569
|
|
|
502
570
|
const term = el('button', 'btn sm', 'Open in terminal');
|
|
503
571
|
term.type = 'button';
|
|
@@ -508,13 +576,34 @@ class Pane {
|
|
|
508
576
|
const termRow = el('div', 'chat-ro-row');
|
|
509
577
|
termRow.append(term, termText);
|
|
510
578
|
|
|
511
|
-
|
|
579
|
+
// Back to the desktop app: the command, to copy and run where the app is.
|
|
580
|
+
const deskLine = el('code', 'chat-ro-cmd');
|
|
581
|
+
const deskCopy = el('button', 'btn sm', 'Copy');
|
|
582
|
+
deskCopy.type = 'button';
|
|
583
|
+
deskCopy.addEventListener('click', () => { if (deskLine.dataset.cmd) navigator.clipboard?.writeText(deskLine.dataset.cmd).then(() => { deskCopy.textContent = 'Copied'; }, () => {}); });
|
|
584
|
+
const deskText = el('span', 'sub');
|
|
585
|
+
deskText.append('Open it in the desktop app again: ', deskLine);
|
|
586
|
+
const deskRow = el('div', 'chat-ro-row');
|
|
587
|
+
deskRow.append(deskCopy, deskText);
|
|
588
|
+
|
|
589
|
+
this.roEl.replaceChildren(lead, contRow, termRow, deskRow);
|
|
512
590
|
|
|
513
|
-
// The
|
|
591
|
+
// The commands are the server's, read before anything runs. One that could not be worked out is said so.
|
|
514
592
|
fetch(this.api(`/api/session/${encodeURIComponent(this.id)}/terminal`), { cache: 'no-store' })
|
|
515
593
|
.then((r) => r.json())
|
|
516
|
-
.then((r) => {
|
|
517
|
-
|
|
594
|
+
.then((r) => {
|
|
595
|
+
line.textContent = r?.plan?.line ?? 'not measured — no working directory or session id recorded';
|
|
596
|
+
// On Windows the command is a PowerShell line (no one line works in cmd and PowerShell 5.1 both): say so.
|
|
597
|
+
deskLine.textContent = r?.desktop?.line ?? 'not measured — no working directory or session id recorded';
|
|
598
|
+
if (r?.desktop?.line) deskLine.dataset.cmd = r.desktop.line; else deskCopy.disabled = true;
|
|
599
|
+
if (r?.desktop?.shell === 'PowerShell') deskText.append(' (in PowerShell)');
|
|
600
|
+
contText.textContent = r?.continueAs === 'same'
|
|
601
|
+
? 'Continues this same session in Studio: its id and history stay, and the desktop app or a terminal that opens it later sees what you add here. Nothing else has it open now.'
|
|
602
|
+
: r?.continueAs === 'copy'
|
|
603
|
+
? 'It is open somewhere else right now, so Studio starts a copy: the original does not see this, and messages here never reach your terminal or the desktop app.'
|
|
604
|
+
: FALLBACK;
|
|
605
|
+
})
|
|
606
|
+
.catch((e) => { line.textContent = `not read — ${e.message}`; deskLine.textContent = `not read — ${e.message}`; deskCopy.disabled = true; });
|
|
518
607
|
}
|
|
519
608
|
|
|
520
609
|
paintState() {
|
|
@@ -591,6 +680,7 @@ export class Chat {
|
|
|
591
680
|
this.headers = headers;
|
|
592
681
|
this.hooks = hooks;
|
|
593
682
|
this.panes = new Map();
|
|
683
|
+
this.hub = new OwnedStream(api);
|
|
594
684
|
this.activeId = null;
|
|
595
685
|
this.onActivate = () => {};
|
|
596
686
|
// (pane, { pending }) — a session's waiting requests changed.
|
|
@@ -654,7 +744,7 @@ export class Chat {
|
|
|
654
744
|
}
|
|
655
745
|
if (!pane) {
|
|
656
746
|
pane = new Pane(session, {
|
|
657
|
-
api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(),
|
|
747
|
+
api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(), hub: this.hub,
|
|
658
748
|
onPermissions: (p, rec) => this.onPermissions(p, rec),
|
|
659
749
|
});
|
|
660
750
|
this.panesEl.append(pane.root);
|
|
@@ -276,8 +276,9 @@ export function tallestFor(paneHeight) {
|
|
|
276
276
|
return Math.max(AUTO.tallest, fits);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
/** A session's card: one line taller
|
|
280
|
-
export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0)
|
|
279
|
+
/** A session's card: one line taller for commands running in the background, one for refused agent calls. */
|
|
280
|
+
export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0)
|
|
281
|
+
+ (n?.refusedCalls?.length ? SIZE.session.line : 0);
|
|
281
282
|
|
|
282
283
|
/* ----------------------------------------------------------------- plan --- */
|
|
283
284
|
|
|
@@ -64,3 +64,17 @@ function clock(ms) {
|
|
|
64
64
|
const two = (n) => String(n).padStart(2, '0');
|
|
65
65
|
return `${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Is the server older than the files it serves? The page is read from disk on every load; the server keeps the
|
|
70
|
+
* code it started with. After an update the two disagree, and a view the old server has no route for answered
|
|
71
|
+
* 404 (seen as "Not read — HTTP 404" on the Map view). /api/health says `code.changed` when the server's files
|
|
72
|
+
* changed since it started; a server with no `code` at all predates the field, and is older than this page.
|
|
73
|
+
* Returns the sentence to show, or null.
|
|
74
|
+
*/
|
|
75
|
+
export const RESTART_NOTE = 'Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.';
|
|
76
|
+
export function staleServerNote(health) {
|
|
77
|
+
if (!health || typeof health !== 'object') return null;
|
|
78
|
+
if (!health.code || typeof health.code !== 'object') return RESTART_NOTE;
|
|
79
|
+
return health.code.changed === true ? RESTART_NOTE : null;
|
|
80
|
+
}
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
// What the Map view draws, worked out from a crew map. No page in it.
|
|
2
2
|
//
|
|
3
3
|
// The map is Crewforth's (server/lib/crew-map.js reads it). This file puts each node in a band, says which lines
|
|
4
|
-
// a selection lights, orders the findings and words them. It invents no layer: a module
|
|
5
|
-
//
|
|
4
|
+
// a selection lights, orders the findings and words them. It invents no layer: a module's layer is the one /crew-map
|
|
5
|
+
// wrote for it (citing the lines it rests on); without one, its band is what the scan shows of it, and a module whose
|
|
6
|
+
// place the scan does not show goes to "Unplaced". A finding the scanner hides by default stays hidden until asked.
|
|
6
7
|
|
|
8
|
+
// The layers /crew-map may write (eval/lib/crew-map/narrative.mjs LAYERS), set between the bands the scan alone
|
|
9
|
+
// gives, so a map with no layer is drawn in the same order as before.
|
|
10
|
+
export const LAYER_KEYS = ['ui', 'api', 'service', 'data', 'infra', 'tool', 'library'];
|
|
7
11
|
export const BANDS = [
|
|
12
|
+
['ui', 'UI'],
|
|
8
13
|
['clients', 'Clients'],
|
|
14
|
+
['api', 'API'],
|
|
9
15
|
['entry', 'Entry points'],
|
|
16
|
+
['service', 'Service'],
|
|
10
17
|
['core', 'Core'],
|
|
18
|
+
['data', 'Data'],
|
|
11
19
|
['infra', 'Infrastructure'],
|
|
20
|
+
['library', 'Library'],
|
|
21
|
+
['tool', 'Tools'],
|
|
12
22
|
['external', 'External'],
|
|
13
23
|
['unplaced', 'Unplaced'],
|
|
14
24
|
];
|
|
@@ -37,8 +47,10 @@ export function findingModules(f, modules) {
|
|
|
37
47
|
* one that only calls endpoints → Clients
|
|
38
48
|
* one another module of this project references → Core · one with an entry point and nothing above → Entry points
|
|
39
49
|
* anything else → Unplaced, so a guess is never drawn as a fact.
|
|
50
|
+
* A module /crew-map gave a layer goes to that layer's band first.
|
|
40
51
|
*/
|
|
41
52
|
export function bandOf(node, doc) {
|
|
53
|
+
if (node.kind === 'module' && LAYER_KEYS.includes(node.narrative?.layer)) return node.narrative.layer;
|
|
42
54
|
if (node.kind === 'external') return 'external';
|
|
43
55
|
if (node.kind === 'service') return 'infra';
|
|
44
56
|
if (node.kind === 'client') return 'clients';
|
|
@@ -201,6 +213,52 @@ export function cardSub(node, doc) {
|
|
|
201
213
|
return [tech || node.stack, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
|
|
202
214
|
}
|
|
203
215
|
|
|
216
|
+
/**
|
|
217
|
+
* What a module's card says, as the Map draws it: a kind line (its domain, else its stack, and how many endpoints it
|
|
218
|
+
* provides), up to three of what it is built with, and the outside services it calls, folded into one line under it.
|
|
219
|
+
*/
|
|
220
|
+
export const CARD_CHIPS = 3;
|
|
221
|
+
export function cardOf(node, doc) {
|
|
222
|
+
const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
|
|
223
|
+
const kind = [node.narrative?.domain ?? node.stack ?? null, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
|
|
224
|
+
const tech = (node.tech ?? []).map((t) => t.replace(/@.*$/, ''));
|
|
225
|
+
return { kind, chips: tech.slice(0, CARD_CHIPS), more: Math.max(0, tech.length - CARD_CHIPS), outside: outsideOf(node, doc) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The outside services a module calls, by name, in the order they are named: what its card folds under it. */
|
|
229
|
+
export function outsideOf(node, doc) {
|
|
230
|
+
const byId = new Map((doc.nodes ?? []).map((n) => [n.id, n]));
|
|
231
|
+
const names = [];
|
|
232
|
+
for (const e of doc.edges ?? []) {
|
|
233
|
+
if (e.from !== node.id) continue;
|
|
234
|
+
const to = byId.get(e.to);
|
|
235
|
+
if (to?.kind !== 'external') continue;
|
|
236
|
+
const name = to.label && to.label !== 'unknown' ? to.label : (to.hosts ?? [])[0] ?? to.id.replace(/^ext:[^:]+:/, '');
|
|
237
|
+
if (!names.includes(name)) names.push(name);
|
|
238
|
+
}
|
|
239
|
+
return names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The tour /crew-map wrote, the stops whose node is on this map, each with the node's name. */
|
|
243
|
+
export function tourOf(doc) {
|
|
244
|
+
const byId = new Map((doc.nodes ?? []).map((n) => [n.id, n]));
|
|
245
|
+
return (Array.isArray(doc.tour) ? doc.tour : []).filter((s) => s && byId.has(s.node) && typeof s.text === 'string')
|
|
246
|
+
.map((s) => ({ node: s.node, name: byId.get(s.node).name ?? byId.get(s.node).label ?? s.node, text: s.text, cites: (s.cites ?? []).filter((c) => typeof c === 'string') }));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The nodes a search names: by name, folder, what they are built with, their domain, the folders inside them and
|
|
251
|
+
* what their README says. Case does not matter; an empty search names none.
|
|
252
|
+
*/
|
|
253
|
+
export function searchOf(query, nodes) {
|
|
254
|
+
const q = String(query ?? '').trim().toLowerCase();
|
|
255
|
+
if (!q) return new Set();
|
|
256
|
+
const hay = (n) => [n.name, n.label, n.path, n.stack, ...(n.tech ?? []), ...(n.hosts ?? []), n.narrative?.domain, n.narrative?.layer,
|
|
257
|
+
...(n.structure?.folders ?? []).map((f) => f.folder), ...(n.about?.evidence ?? []).map((e) => e.text)]
|
|
258
|
+
.filter((x) => typeof x === 'string').join('\n').toLowerCase();
|
|
259
|
+
return new Set(nodes.filter((n) => hay(n).includes(q)).map((n) => n.id));
|
|
260
|
+
}
|
|
261
|
+
|
|
204
262
|
/** The summary pills: what the map holds, and how many findings the list shows. */
|
|
205
263
|
export function summaryOf(doc, findings) {
|
|
206
264
|
const n = (k) => (doc.nodes ?? []).filter((x) => x.kind === k).length;
|
|
@@ -247,6 +305,16 @@ export function inspectOf(node, doc, edges, findings) {
|
|
|
247
305
|
narrative: typeof node.narrative?.text === 'string' ? node.narrative.text : null,
|
|
248
306
|
narrativeCites: Array.isArray(node.narrative?.cites) ? node.narrative.cites.filter((c) => typeof c === 'string') : [],
|
|
249
307
|
narrativeNote: 'Not written yet: this map holds no description of the module, only what its files show.',
|
|
308
|
+
// /crew-map's layer and domain, when it wrote them; the band is the layer's (bandOf).
|
|
309
|
+
layer: LAYER_KEYS.includes(node.narrative?.layer) ? (BANDS.find(([k]) => k === node.narrative.layer)?.[1] ?? null) : null,
|
|
310
|
+
domain: typeof node.narrative?.domain === 'string' ? node.narrative.domain : null,
|
|
311
|
+
// What is inside it: the entry point the scan found, its folders with how many source files each holds, tests apart.
|
|
312
|
+
inside: node.kind === 'module' ? {
|
|
313
|
+
entry: node.entryPoint?.evidence?.[0] ? `${node.entryPoint.evidence[0].file}:${node.entryPoint.evidence[0].line}` : (node.entryPoint?.file ?? null),
|
|
314
|
+
folders: (node.structure?.folders ?? []).map((f) => ({ folder: f.folder, files: f.files })),
|
|
315
|
+
other: node.structure?.otherFiles ?? 0,
|
|
316
|
+
tests: node.structure?.testFiles ?? 0,
|
|
317
|
+
} : null,
|
|
250
318
|
connectsTo: out,
|
|
251
319
|
connections: { out, in: into },
|
|
252
320
|
byKind: Object.entries(byKind).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
@@ -274,7 +342,8 @@ export function emptyNote(data) {
|
|
|
274
342
|
return null;
|
|
275
343
|
}
|
|
276
344
|
|
|
277
|
-
|
|
345
|
+
// A module card holds its name and kind, a row of what it is built with, and the line of outside services under it.
|
|
346
|
+
export const CARD = { module: [280, 104], service: [280, 104], client: [280, 104], group: [280, 104], external: [104, 52] };
|
|
278
347
|
const GAP_X = 24;
|
|
279
348
|
const GAP_Y = 20;
|
|
280
349
|
const LEFT = 140; // the band's label sits in this column
|