crewforth 3.2.0-rc.2 → 3.2.0-rc.4
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 +118 -5
- package/README.md +2 -0
- package/VERSION +1 -1
- package/adopt.sh +10 -1
- package/bin/cli.js +3 -2
- package/kit/CLAUDE.md +3 -3
- package/kit/agents/crew-commit-agent.md +6 -4
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/eval/doctor.sh +13 -2
- 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 +62 -1
- 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-json.awk +4 -0
- package/kit/eval/lib/statusline.sh +1 -1
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +208 -21
- package/kit/hooks/agent-outcome.sh +18 -0
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +72 -26
- package/kit/hooks/guard-bash.sh +49 -34
- package/kit/hooks/guard-write.sh +3 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +21 -0
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/settings.json +12 -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 +74 -22
- 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/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 +69 -7
- package/kit/studio/web/canvas.js +14 -2
- package/kit/studio/web/chat.js +32 -7
- package/kit/studio/web/graph-plan.js +3 -2
- 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 +34 -0
- package/kit/studio/web/tokens.css +6 -0
- package/package.json +1 -1
- package/start.sh +14 -1
|
@@ -1,60 +1,129 @@
|
|
|
1
1
|
// remote.js — read-only viewers.
|
|
2
2
|
//
|
|
3
|
-
// The panel's own token can start a session and answer a tool call. Handing that token to a phone
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// request is refused here before any handler sees it.
|
|
3
|
+
// The panel's own token can start a session and answer a tool call. Handing that token to a phone hands the phone
|
|
4
|
+
// all of it. A viewer token is the smaller thing: it reads what the panel reads and can change nothing. The server
|
|
5
|
+
// decides that, not the page — a viewer's request is refused here before any handler sees it.
|
|
7
6
|
//
|
|
8
|
-
// THE
|
|
9
|
-
//
|
|
7
|
+
// THE WAY IN FROM ANOTHER DEVICE IS THE SAME WI-FI. While at least one viewer is live, the server also listens on
|
|
8
|
+
// this machine's Wi-Fi address (index.js), and there it answers a viewer's token and nothing else: the panel's own
|
|
9
|
+
// token is not accepted from the network at all. With no viewer, nothing listens beyond 127.0.0.1.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
// survived a restart would be a credential nobody remembers handing out.
|
|
11
|
+
// A viewer lasts EIGHT HOURS, or until it is revoked, or until the server stops — whichever comes first. Kept in
|
|
12
|
+
// memory, like the owner's token: a link that survived a restart would be a credential nobody remembers handing out.
|
|
13
|
+
// An expired token is remembered as expired (not as unknown) so the phone can say "Session expired" rather than
|
|
14
|
+
// "no access", and only for as long as the server runs.
|
|
15
|
+
//
|
|
16
|
+
// TRANSCRIPTS ARE HIDDEN by default: a viewer made without `transcripts` is answered with no text that came from a
|
|
17
|
+
// transcript (lib/redact.js does that on the server; the page hiding it would not be enough).
|
|
13
18
|
import { randomUUID } from 'node:crypto';
|
|
14
19
|
|
|
15
20
|
export const MAX_VIEWERS = 8;
|
|
21
|
+
export const VIEWER_TTL_MS = 8 * 60 * 60 * 1000;
|
|
16
22
|
const LABEL_MAX = 40;
|
|
23
|
+
const LOG_MAX = 200;
|
|
17
24
|
|
|
18
25
|
const viewers = new Map(); // id -> viewer
|
|
19
26
|
const byToken = new Map(); // token -> viewer
|
|
27
|
+
const expired = new Map(); // token -> label, for "Session expired"
|
|
28
|
+
const log = []; // what happened to viewers' links, newest last
|
|
29
|
+
|
|
30
|
+
let clock = () => Date.now();
|
|
31
|
+
let onChange = () => {};
|
|
32
|
+
let note = () => {};
|
|
33
|
+
|
|
34
|
+
/** For the self-check: a clock it can move (eight hours are not waited for). */
|
|
35
|
+
export function _setClock(fn) { clock = fn ?? (() => Date.now()); }
|
|
36
|
+
/** index.js opens and closes the Wi-Fi listener when the number of live viewers changes. */
|
|
37
|
+
export function onViewersChange(fn) { onChange = fn ?? (() => {}); }
|
|
38
|
+
/** Where a log line also goes (the server's own output). */
|
|
39
|
+
export function onLog(fn) { note = fn ?? (() => {}); }
|
|
40
|
+
|
|
41
|
+
function record(entry) {
|
|
42
|
+
const e = { at: clock(), ...entry };
|
|
43
|
+
log.push(e);
|
|
44
|
+
if (log.length > LOG_MAX) log.splice(0, log.length - LOG_MAX);
|
|
45
|
+
note(e);
|
|
46
|
+
}
|
|
20
47
|
|
|
21
48
|
const publicOf = (v) => ({
|
|
22
49
|
id: v.id,
|
|
23
50
|
label: v.label,
|
|
24
51
|
createdAt: v.createdAt,
|
|
52
|
+
expiresAt: v.expiresAt,
|
|
53
|
+
transcripts: v.transcripts,
|
|
25
54
|
lastSeenAt: v.lastSeenAt,
|
|
26
55
|
requests: v.requests,
|
|
27
56
|
agent: v.agent,
|
|
28
57
|
open: v.conns.size,
|
|
58
|
+
addresses: [...v.addresses],
|
|
29
59
|
});
|
|
30
60
|
|
|
31
61
|
/** Make a viewer. The token is in this answer and in no later one. */
|
|
32
|
-
export function addViewer(label) {
|
|
62
|
+
export function addViewer(label, { transcripts = false } = {}) {
|
|
63
|
+
sweep();
|
|
33
64
|
if (viewers.size >= MAX_VIEWERS) {
|
|
34
65
|
return { ok: false, reason: `${MAX_VIEWERS} viewers already; revoke one first` };
|
|
35
66
|
}
|
|
36
67
|
const named = typeof label === 'string' ? label.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX) : '';
|
|
68
|
+
const now = clock();
|
|
37
69
|
const v = {
|
|
38
70
|
id: randomUUID().slice(0, 8),
|
|
39
71
|
label: named || `Viewer ${viewers.size + 1}`,
|
|
40
72
|
token: randomUUID(),
|
|
41
|
-
createdAt:
|
|
73
|
+
createdAt: now,
|
|
74
|
+
expiresAt: now + VIEWER_TTL_MS,
|
|
75
|
+
transcripts: transcripts === true,
|
|
42
76
|
lastSeenAt: null,
|
|
43
77
|
requests: 0,
|
|
44
78
|
agent: null,
|
|
45
79
|
conns: new Set(),
|
|
80
|
+
addresses: new Set(),
|
|
46
81
|
};
|
|
47
82
|
viewers.set(v.id, v);
|
|
48
83
|
byToken.set(v.token, v);
|
|
84
|
+
record({ event: 'added', viewer: v.label, transcripts: v.transcripts });
|
|
85
|
+
onChange(viewers.size);
|
|
49
86
|
return { ok: true, viewer: { ...publicOf(v), token: v.token } };
|
|
50
87
|
}
|
|
51
88
|
|
|
52
89
|
export function listViewers() {
|
|
90
|
+
sweep();
|
|
53
91
|
return [...viewers.values()].map(publicOf);
|
|
54
92
|
}
|
|
55
93
|
|
|
94
|
+
export function liveCount() {
|
|
95
|
+
sweep();
|
|
96
|
+
return viewers.size;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function remoteLog(n = 50) {
|
|
100
|
+
return log.slice(-n);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The viewer this token belongs to, if it is live; `{ expired: label }` if it was one and its eight hours are up. */
|
|
56
104
|
export function viewerByToken(token) {
|
|
57
|
-
|
|
105
|
+
if (typeof token !== 'string') return null;
|
|
106
|
+
const v = byToken.get(token);
|
|
107
|
+
if (v && clock() >= v.expiresAt) { end(v, 'expired'); return { expired: v.label }; }
|
|
108
|
+
if (v) return v;
|
|
109
|
+
if (expired.has(token)) return { expired: expired.get(token) };
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function end(v, why) {
|
|
114
|
+
viewers.delete(v.id);
|
|
115
|
+
byToken.delete(v.token);
|
|
116
|
+
if (why === 'expired') expired.set(v.token, v.label);
|
|
117
|
+
for (const res of v.conns) { try { res.destroy(); } catch { /* already gone */ } }
|
|
118
|
+
v.conns.clear();
|
|
119
|
+
record({ event: why, viewer: v.label });
|
|
120
|
+
onChange(viewers.size);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** End every viewer whose eight hours are up. Called on every read of the list and by index.js's timer. */
|
|
124
|
+
export function sweep() {
|
|
125
|
+
const now = clock();
|
|
126
|
+
for (const v of [...viewers.values()]) if (now >= v.expiresAt) end(v, 'expired');
|
|
58
127
|
}
|
|
59
128
|
|
|
60
129
|
/**
|
|
@@ -64,23 +133,38 @@ export function viewerByToken(token) {
|
|
|
64
133
|
export function revokeViewer(id) {
|
|
65
134
|
const v = viewers.get(id);
|
|
66
135
|
if (!v) return { ok: true, revoked: false };
|
|
67
|
-
|
|
68
|
-
byToken.delete(v.token);
|
|
69
|
-
for (const res of v.conns) { try { res.destroy(); } catch { /* already gone */ } }
|
|
70
|
-
v.conns.clear();
|
|
136
|
+
end(v, 'revoked');
|
|
71
137
|
return { ok: true, revoked: true };
|
|
72
138
|
}
|
|
73
139
|
|
|
74
|
-
/** Count a viewer's request and remember its response for as long as it is open. */
|
|
140
|
+
/** Count a viewer's request and remember its response for as long as it is open. A new address is logged once. */
|
|
75
141
|
export function noteRequest(v, req, res) {
|
|
76
142
|
v.requests += 1;
|
|
77
|
-
v.lastSeenAt =
|
|
143
|
+
v.lastSeenAt = clock();
|
|
78
144
|
const ua = req.headers['user-agent'];
|
|
79
145
|
v.agent = typeof ua === 'string' ? ua.slice(0, 160) : null;
|
|
146
|
+
const ip = req.socket?.remoteAddress ?? null;
|
|
147
|
+
if (ip && !v.addresses.has(ip)) {
|
|
148
|
+
v.addresses.add(ip);
|
|
149
|
+
record({ event: 'connected', viewer: v.label, ip, over: req.crewLan ? 'wifi' : 'loopback', result: 'ok' });
|
|
150
|
+
}
|
|
80
151
|
v.conns.add(res);
|
|
81
152
|
res.on('close', () => v.conns.delete(res));
|
|
82
153
|
}
|
|
83
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A request that was turned away: logged with who it claimed to be, from where, and why. The same refusal again
|
|
157
|
+
* (same viewer, address, path and answer) is counted on its entry rather than written again, so a page that keeps
|
|
158
|
+
* asking cannot push everything else out of the log.
|
|
159
|
+
*/
|
|
160
|
+
export function noteRefusal(req, { viewer = null, status, reason }) {
|
|
161
|
+
const e = { event: 'refused', viewer, ip: req.socket?.remoteAddress ?? null, over: req.crewLan ? 'wifi' : 'loopback',
|
|
162
|
+
result: `${status} ${reason}`, path: String(req.url ?? '').split('?')[0].slice(0, 120) };
|
|
163
|
+
const same = log.findLast((x) => x.event === 'refused' && x.viewer === e.viewer && x.ip === e.ip && x.path === e.path && x.result === e.result);
|
|
164
|
+
if (same) { same.count = (same.count ?? 1) + 1; same.lastAt = clock(); return; }
|
|
165
|
+
record(e);
|
|
166
|
+
}
|
|
167
|
+
|
|
84
168
|
/**
|
|
85
169
|
* What a viewer may ask for: a GET of anything the panel reads, and nothing about viewers themselves.
|
|
86
170
|
* The list of viewers is the owner's; a viewer that could read it could count who else is watching.
|
|
@@ -95,5 +179,7 @@ export function viewerMay(method, pathname) {
|
|
|
95
179
|
|
|
96
180
|
/** For the self-check: start from nothing. */
|
|
97
181
|
export function _reset() {
|
|
98
|
-
for (const
|
|
182
|
+
for (const v of [...viewers.values()]) end(v, 'revoked');
|
|
183
|
+
expired.clear();
|
|
184
|
+
log.length = 0;
|
|
99
185
|
}
|
|
@@ -290,7 +290,7 @@ class OwnedSession {
|
|
|
290
290
|
* answer is "yes": an unreadable fleet must not be the reason two processes
|
|
291
291
|
* end up writing one transcript.
|
|
292
292
|
*/
|
|
293
|
-
async function isHeldOpen(id) {
|
|
293
|
+
export async function isHeldOpen(id) {
|
|
294
294
|
try {
|
|
295
295
|
const fleet = await getFleet();
|
|
296
296
|
const rows = fleet?.sessions ?? fleet ?? [];
|
|
@@ -17,21 +17,60 @@ function q(s) {
|
|
|
17
17
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** PowerShell literal string: a quote is doubled, nothing else is special inside one. */
|
|
21
|
+
function psq(s) {
|
|
22
|
+
return `'${String(s).replace(/'/g, "''")}'`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A session id is a UUID. It comes from the URL, and on Windows it ends up in a line cmd.exe interprets, where a
|
|
27
|
+
* quote is not a quote (cmd hands `'` to the program as part of the id) and `&` starts a second command. So
|
|
28
|
+
* nothing is built for anything else, and an id that passes needs no quoting in any shell.
|
|
29
|
+
*/
|
|
30
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
31
|
+
export function isSessionId(s) {
|
|
32
|
+
return typeof s === 'string' && SESSION_ID.test(s);
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/** AppleScript string literal: backslashes and quotes, nothing else. */
|
|
21
36
|
function osaQuote(s) {
|
|
22
37
|
return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
23
38
|
}
|
|
24
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The command that opens the session in the Claude Desktop app again (`claude --desktop`, CLI 2.1.299), from its
|
|
42
|
+
* own directory: `--resume <id>` looks for the session among that directory's. On Windows it is a PowerShell line
|
|
43
|
+
* (5.1 and 7; Windows Terminal opens PowerShell): cmd needs `&&` and does not take single quotes, PowerShell 5.1
|
|
44
|
+
* rejects `&&`, and no one line works in both. `shell` says which, so the panel can say so.
|
|
45
|
+
*/
|
|
46
|
+
export function desktopLine({ cwd, sessionId, platform = process.platform }) {
|
|
47
|
+
if (!isSessionId(sessionId)) return null;
|
|
48
|
+
if (platform === 'win32') {
|
|
49
|
+
return { shell: 'PowerShell', line: `Set-Location -LiteralPath ${psq(cwd)}; claude --desktop --resume ${sessionId}` };
|
|
50
|
+
}
|
|
51
|
+
return { shell: 'sh', line: `cd ${q(cwd)} && claude --desktop --resume ${q(sessionId)}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
/**
|
|
26
55
|
* Build the launch for this platform.
|
|
27
56
|
* Returns null when nothing here knows how to open a terminal, so the caller
|
|
28
57
|
* can say so rather than fail silently.
|
|
29
58
|
*/
|
|
30
|
-
export function plan({ cwd, sessionId,
|
|
31
|
-
|
|
59
|
+
export function plan({ cwd, sessionId, platform = process.platform }) {
|
|
60
|
+
if (!isSessionId(sessionId)) return null;
|
|
61
|
+
|
|
62
|
+
if (platform === 'win32') {
|
|
63
|
+
// Windows Terminal when present, the console host otherwise. wt sets the directory itself; what runs inside is
|
|
64
|
+
// cmd.exe, so the line shown is a cmd line: double quotes, `cd /d` for another drive, and the id bare (a UUID).
|
|
65
|
+
const inner = `claude --resume ${sessionId}`;
|
|
66
|
+
const wt = ['wt.exe', '-d', cwd, 'cmd.exe', '/k', inner];
|
|
67
|
+
return { platform: 'win32', via: 'wt.exe', shell: 'cmd', line: `cd /d "${cwd}" && ${inner}`, argv: wt };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const inner = `claude --resume ${q(sessionId)}`;
|
|
32
71
|
const line = `cd ${q(cwd)} && ${inner}`;
|
|
33
72
|
|
|
34
|
-
if (
|
|
73
|
+
if (platform === 'darwin') {
|
|
35
74
|
return {
|
|
36
75
|
platform: 'darwin',
|
|
37
76
|
via: 'Terminal.app',
|
|
@@ -41,12 +80,6 @@ export function plan({ cwd, sessionId, command }) {
|
|
|
41
80
|
};
|
|
42
81
|
}
|
|
43
82
|
|
|
44
|
-
if (process.platform === 'win32') {
|
|
45
|
-
// Windows Terminal when present, the console host otherwise.
|
|
46
|
-
const wt = ['wt.exe', '-d', cwd, 'cmd.exe', '/k', inner];
|
|
47
|
-
return { platform: 'win32', via: 'wt.exe', line, argv: wt };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
83
|
// Linux and the rest: whichever emulator is actually installed. Names are
|
|
51
84
|
// tried by running them, not by resolving them — a name on PATH that fails
|
|
52
85
|
// to start is the failure mode this ordering exists for.
|
|
@@ -57,14 +90,19 @@ export function plan({ cwd, sessionId, command }) {
|
|
|
57
90
|
['xfce4-terminal', ['--working-directory', cwd, '-e', `bash -lc ${q(line)}`]],
|
|
58
91
|
['xterm', ['-e', 'bash', '-lc', line]],
|
|
59
92
|
];
|
|
60
|
-
return { platform
|
|
93
|
+
return { platform, via: 'candidates', line, candidates };
|
|
61
94
|
}
|
|
62
95
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Launch it, detached, and report what was run. What runs is always `claude --resume <id>`: the route used to take a
|
|
98
|
+
* `command` from the request body and run it in place of that, with no id check, and the panel never sent one. An
|
|
99
|
+
* unused way to run anything in a terminal, behind a token that remote access puts on the network, was removed.
|
|
100
|
+
*/
|
|
101
|
+
export function open({ cwd, sessionId }) {
|
|
65
102
|
if (!cwd || !fs.existsSync(cwd)) return { ok: false, reason: `no such directory: ${cwd}` };
|
|
66
103
|
|
|
67
|
-
|
|
104
|
+
if (!isSessionId(sessionId)) return { ok: false, reason: `not a session id: ${sessionId}` };
|
|
105
|
+
const p = plan({ cwd, sessionId });
|
|
68
106
|
if (!p) return { ok: false, reason: `no terminal launcher known for ${process.platform}` };
|
|
69
107
|
|
|
70
108
|
const attempts = p.argv ? [p.argv] : p.candidates.map(([bin, args]) => [bin, ...args]);
|
|
@@ -98,3 +98,35 @@ export function contextFill(records) {
|
|
|
98
98
|
}
|
|
99
99
|
return total;
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the context is made of since the last compaction: the share of its text that is tool output, and how many
|
|
104
|
+
* times the session was compacted, with the last one's tokens before and after. Measured in characters of the main
|
|
105
|
+
* session's records (text, a tool call's input, a tool's result), not tokens: the transcript keeps no per-block
|
|
106
|
+
* token count. A compaction is the transcript's own `compact_boundary` record.
|
|
107
|
+
*/
|
|
108
|
+
export function contextMix(records) {
|
|
109
|
+
let start = 0; let compactions = 0; let last = null;
|
|
110
|
+
records.forEach((r, i) => {
|
|
111
|
+
if (r?.isSidechain !== true && r?.type === 'system' && r?.subtype === 'compact_boundary') { start = i + 1; compactions += 1; last = r.compactMetadata ?? null; }
|
|
112
|
+
});
|
|
113
|
+
const size = (b) => {
|
|
114
|
+
if (typeof b === 'string') return b.length;
|
|
115
|
+
if (b?.type === 'text') return String(b.text ?? '').length;
|
|
116
|
+
if (b?.type === 'tool_use') return JSON.stringify(b.input ?? {}).length;
|
|
117
|
+
if (b?.type === 'tool_result') return typeof b.content === 'string' ? b.content.length : (Array.isArray(b.content) ? b.content.reduce((n, x) => n + (x?.type === 'text' ? String(x.text ?? '').length : 0), 0) : 0);
|
|
118
|
+
return 0;
|
|
119
|
+
};
|
|
120
|
+
let tool = 0; let all = 0;
|
|
121
|
+
for (let i = start; i < records.length; i += 1) {
|
|
122
|
+
const r = records[i];
|
|
123
|
+
if (r?.isSidechain === true || (r?.type !== 'user' && r?.type !== 'assistant')) continue;
|
|
124
|
+
const c = r?.message?.content;
|
|
125
|
+
for (const b of Array.isArray(c) ? c : [c]) { const n = size(b); all += n; if (b?.type === 'tool_result') tool += n; }
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
toolShare: all ? Math.round((100 * tool) / all) : null,
|
|
129
|
+
compactions,
|
|
130
|
+
lastCompact: last ? { trigger: last.trigger ?? null, before: last.preTokens ?? null, after: last.postTokens ?? null } : null,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/kit/studio/web/app.js
CHANGED
|
@@ -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 */ }
|
|
@@ -2058,6 +2084,9 @@ let handed = null; // the link of the viewer just added: the server s
|
|
|
2058
2084
|
async function pollRemote() {
|
|
2059
2085
|
if (role !== 'owner') return;
|
|
2060
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;
|
|
2061
2090
|
paintMachines();
|
|
2062
2091
|
}
|
|
2063
2092
|
|
|
@@ -2074,16 +2103,25 @@ async function remoteWrite(method, path, body) {
|
|
|
2074
2103
|
}
|
|
2075
2104
|
}
|
|
2076
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
|
+
|
|
2077
2111
|
async function addViewer(button) {
|
|
2078
2112
|
button.disabled = true;
|
|
2079
|
-
const res = await remoteWrite('POST', '/api/remote', {});
|
|
2113
|
+
const res = await remoteWrite('POST', '/api/remote', { transcripts: showTranscripts });
|
|
2080
2114
|
if (!res.ok) {
|
|
2081
2115
|
toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
|
|
2082
2116
|
button.disabled = false;
|
|
2083
2117
|
return;
|
|
2084
2118
|
}
|
|
2085
2119
|
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2086
|
-
|
|
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;
|
|
2087
2125
|
paintMachines();
|
|
2088
2126
|
}
|
|
2089
2127
|
|
|
@@ -2114,6 +2152,7 @@ function viewerRow(v) {
|
|
|
2114
2152
|
? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
|
|
2115
2153
|
: 'link not opened yet';
|
|
2116
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(', ')}` : ''}`));
|
|
2117
2156
|
return row;
|
|
2118
2157
|
}
|
|
2119
2158
|
|
|
@@ -2131,9 +2170,24 @@ function remoteSection() {
|
|
|
2131
2170
|
head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
|
|
2132
2171
|
box.append(head);
|
|
2133
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
|
+
|
|
2134
2181
|
if (handed) {
|
|
2135
2182
|
const give = node('div', 'rv-give');
|
|
2136
|
-
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
|
+
}
|
|
2137
2191
|
const url = node('code', 'rv-url', handed.url);
|
|
2138
2192
|
const copy = node('button', 'btn sm', 'Copy link');
|
|
2139
2193
|
copy.type = 'button';
|
|
@@ -2150,7 +2204,7 @@ function remoteSection() {
|
|
|
2150
2204
|
for (const v of list) box.append(viewerRow(v));
|
|
2151
2205
|
if (!list.length) {
|
|
2152
2206
|
box.append(node('div', 'sub rv-say',
|
|
2153
|
-
'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.'));
|
|
2154
2208
|
}
|
|
2155
2209
|
return box;
|
|
2156
2210
|
}
|
|
@@ -2636,7 +2690,9 @@ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnima
|
|
|
2636
2690
|
// They survive a page reload as long as the server does.
|
|
2637
2691
|
let ownedBusy = false;
|
|
2638
2692
|
async function pollOwned() {
|
|
2639
|
-
|
|
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;
|
|
2640
2696
|
ownedBusy = true;
|
|
2641
2697
|
try {
|
|
2642
2698
|
const r = await getJson('/api/owned');
|
|
@@ -2822,8 +2878,14 @@ getJson('/api/health').then((h) => {
|
|
|
2822
2878
|
// A Map read before this answer showed the old server's 404; read it again so it says why.
|
|
2823
2879
|
if (restartNote) { paintStageNote(); if (view === 'map') loadMap(true); }
|
|
2824
2880
|
role = h.role === 'viewer' ? 'viewer' : 'owner';
|
|
2881
|
+
transcriptsHidden = role === 'viewer' && h.transcripts === false;
|
|
2825
2882
|
document.body.dataset.role = role;
|
|
2826
|
-
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
|
+
}
|
|
2827
2889
|
if (role === 'owner') pollRemote(); else paintMachines();
|
|
2828
2890
|
}).catch(() => { /* the Live indicator says the server is unreachable */ });
|
|
2829
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
|
@@ -208,6 +208,7 @@ class Pane {
|
|
|
208
208
|
this.note(`History not read: ${e.message}`, 'bad');
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
211
|
+
if (conv?.transcriptsHidden) { this.note('Transcripts are hidden on this link', 'muted'); return; }
|
|
211
212
|
if (conv?.measured === false) { this.note(`History not measured — ${conv.reason}`, 'bad'); return; }
|
|
212
213
|
|
|
213
214
|
const frag = document.createDocumentFragment();
|
|
@@ -559,9 +560,12 @@ class Pane {
|
|
|
559
560
|
cont.type = 'button';
|
|
560
561
|
cont.addEventListener('click', () => this.hooks.onContinue?.(this, cont));
|
|
561
562
|
const contRow = el('div', 'chat-ro-row');
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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);
|
|
565
569
|
|
|
566
570
|
const term = el('button', 'btn sm', 'Open in terminal');
|
|
567
571
|
term.type = 'button';
|
|
@@ -572,13 +576,34 @@ class Pane {
|
|
|
572
576
|
const termRow = el('div', 'chat-ro-row');
|
|
573
577
|
termRow.append(term, termText);
|
|
574
578
|
|
|
575
|
-
|
|
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);
|
|
576
588
|
|
|
577
|
-
|
|
589
|
+
this.roEl.replaceChildren(lead, contRow, termRow, deskRow);
|
|
590
|
+
|
|
591
|
+
// The commands are the server's, read before anything runs. One that could not be worked out is said so.
|
|
578
592
|
fetch(this.api(`/api/session/${encodeURIComponent(this.id)}/terminal`), { cache: 'no-store' })
|
|
579
593
|
.then((r) => r.json())
|
|
580
|
-
.then((r) => {
|
|
581
|
-
|
|
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; });
|
|
582
607
|
}
|
|
583
608
|
|
|
584
609
|
paintState() {
|
|
@@ -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
|
|