crewforth 3.0.3 → 3.1.0-rc.1
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 +211 -0
- package/README.md +3 -3
- package/VERSION +1 -1
- package/adopt.sh +86 -34
- package/kit/CLAUDE.md +1 -1
- package/kit/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +7 -3
- package/kit/agents/crew-frontend-expert.md +4 -4
- package/kit/agents/crew-performance-expert.md +1 -1
- package/kit/agents/crew-review-agent.md +1 -1
- package/kit/eval/doctor.sh +57 -3
- package/kit/eval/golden-routing.txt +13 -0
- package/kit/eval/lib/git-bash.sh +11 -2
- package/kit/eval/parser-conformance.sh +1 -0
- package/kit/eval/routing-eval.sh +7 -3
- package/kit/eval/smoke-test.sh +2545 -214
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1608 -103
- package/kit/hooks/guard-commit-scan.sh +138 -19
- package/kit/hooks/guard-powershell.sh +89 -15
- package/kit/hooks/guard-write.sh +98 -22
- package/kit/hooks/no-bash-guard.ps1 +71 -0
- package/kit/hooks/pre-commit +5 -2
- package/kit/hooks/prompt-approval.sh +484 -0
- package/kit/hooks/route-hint.sh +13 -5
- package/kit/hooks/session-rehydrate.sh +18 -0
- package/kit/hooks/skill-trust.sh +17 -2
- package/kit/owned-blobs.tsv +43 -0
- package/kit/settings.json +20 -4
- package/kit/skills/a11y/SKILL.md +1 -0
- package/kit/skills/crew-doctor/SKILL.md +2 -2
- package/kit/skills/frontend/SKILL.md +3 -3
- package/kit/skills/frontend-flutter/SKILL.md +94 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/studio/server/hooks/studio-gate.sh +35 -1
- package/kit/studio/server/index.js +14 -4
- package/kit/studio/server/lib/graph.js +64 -3
- package/kit/studio/server/lib/kit.js +16 -2
- package/kit/studio/server/lib/permissions.js +77 -2
- package/kit/studio/server/lib/projects.js +60 -7
- package/kit/studio/server/lib/session.js +16 -2
- package/kit/studio/web/app.js +1765 -573
- package/kit/studio/web/approvals.js +124 -0
- package/kit/studio/web/canvas.js +932 -885
- package/kit/studio/web/chat.js +322 -145
- package/kit/studio/web/convo.js +174 -0
- package/kit/studio/web/dock.js +221 -0
- package/kit/studio/web/graph-plan.js +457 -0
- package/kit/studio/web/icons/builtin.svg +1 -0
- package/kit/studio/web/index.html +116 -50
- package/kit/studio/web/inspect.js +110 -0
- package/kit/studio/web/list-plan.js +112 -0
- package/kit/studio/web/list.js +157 -0
- package/kit/studio/web/liveness.js +66 -0
- package/kit/studio/web/nav.js +237 -0
- package/kit/studio/web/newsession.js +235 -0
- package/kit/studio/web/press.js +65 -0
- package/kit/studio/web/style.css +1281 -1014
- package/kit/studio/web/theme.js +43 -0
- package/kit/studio/web/timeline-plan.js +291 -0
- package/kit/studio/web/timeline.js +400 -0
- package/kit/studio/web/tokens.css +131 -0
- package/kit/studio/web/toolbar-fit.js +35 -0
- package/package.json +5 -4
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// What a conversation is made of, worked out from records with no page in it.
|
|
2
|
+
//
|
|
3
|
+
// The same shapes come from two places: the server's reading of a transcript (history, and sessions Studio only
|
|
4
|
+
// watches) and the live stream of a session started here. Both go through these functions, so a tool row reads
|
|
5
|
+
// the same whichever way it arrived.
|
|
6
|
+
|
|
7
|
+
// How much of a tool's output the page keeps for a live result: the same tail the server keeps for history.
|
|
8
|
+
const RESULT_TAIL = 4096;
|
|
9
|
+
const SUMMARY_MAX = 80;
|
|
10
|
+
|
|
11
|
+
const DELEGATION_TOOLS = new Set(['Agent', 'Task']);
|
|
12
|
+
|
|
13
|
+
/** A `tool_use` content item as the block the panel draws. */
|
|
14
|
+
export function toolBlock(c) {
|
|
15
|
+
const i = c?.input ?? {};
|
|
16
|
+
return {
|
|
17
|
+
kind: 'tool',
|
|
18
|
+
name: c?.name ?? 'tool',
|
|
19
|
+
label: i.description ?? i.file_path ?? i.command ?? i.pattern ?? i.query ?? null,
|
|
20
|
+
id: typeof c?.id === 'string' ? c.id : null,
|
|
21
|
+
subagentType: typeof i.subagent_type === 'string' ? i.subagent_type : null,
|
|
22
|
+
result: null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A `tool_result` content item, cut to its tail, with the cut said. */
|
|
27
|
+
export function resultFrom(c) {
|
|
28
|
+
const raw = typeof c?.content === 'string'
|
|
29
|
+
? c.content
|
|
30
|
+
: Array.isArray(c?.content) ? c.content.filter((x) => x?.type === 'text').map((x) => x.text).join('\n') : '';
|
|
31
|
+
const truncated = raw.length > RESULT_TAIL;
|
|
32
|
+
return { error: c?.is_error === true, text: truncated ? raw.slice(-RESULT_TAIL) : raw, truncated, length: raw.length };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The tool results in one `user` record: [[toolUseId, result], …]. */
|
|
36
|
+
export function resultsIn(content) {
|
|
37
|
+
if (!Array.isArray(content)) return [];
|
|
38
|
+
return content
|
|
39
|
+
.filter((c) => c?.type === 'tool_result' && typeof c.tool_use_id === 'string')
|
|
40
|
+
.map((c) => [c.tool_use_id, resultFrom(c)]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Is this call the session handing work to an agent? */
|
|
44
|
+
export function isDelegation(block) {
|
|
45
|
+
return block?.kind === 'tool' && DELEGATION_TOOLS.has(block.name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a tool row says after the tool's name and what it was run on.
|
|
50
|
+
*
|
|
51
|
+
* The output's own last line, and nothing made from it: "42 passed" is there because the test runner printed
|
|
52
|
+
* it. A call that has not come back says so, and so does one whose result was not read; neither is drawn as a
|
|
53
|
+
* call that returned nothing.
|
|
54
|
+
*/
|
|
55
|
+
export function summaryOf(block) {
|
|
56
|
+
const r = block?.result;
|
|
57
|
+
if (!r) return { state: 'pending', text: null };
|
|
58
|
+
const lines = r.text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
59
|
+
const last = lines.length ? lines[lines.length - 1] : null;
|
|
60
|
+
const text = last == null ? 'no output' : (last.length > SUMMARY_MAX ? `${last.slice(0, SUMMARY_MAX - 1)}…` : last);
|
|
61
|
+
return { state: r.error ? 'error' : 'ok', text };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The output as it is shown when a row is opened, with what was left out said first. */
|
|
65
|
+
export function outputOf(block) {
|
|
66
|
+
const r = block?.result;
|
|
67
|
+
if (!r) return null;
|
|
68
|
+
if (!r.text) return '(no output)';
|
|
69
|
+
return r.truncated ? `… ${(r.length - r.text.length).toLocaleString('en-US')} earlier characters not shown\n${r.text}` : r.text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The card a delegation is drawn as: the agent's type, its task, and — when the graph knows the agent that call
|
|
74
|
+
* became — its status. An agent the graph has not seen yet has no status here; none is guessed.
|
|
75
|
+
*/
|
|
76
|
+
export function delegationCard(block, nodes) {
|
|
77
|
+
const node = block.id ? (nodes ?? []).find((n) => n.kind === 'agent' && n.toolUseId === block.id) ?? null : null;
|
|
78
|
+
return {
|
|
79
|
+
type: block.subagentType ?? node?.agentType ?? 'agent',
|
|
80
|
+
task: block.label ?? node?.description ?? '',
|
|
81
|
+
agentId: node?.id ?? null,
|
|
82
|
+
status: node?.status ?? null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The calls the viewer allowed and Claude Code refused anyway.
|
|
88
|
+
*
|
|
89
|
+
* An answer given in the dock is not the last word: the harness has its own checks after the hook, and a
|
|
90
|
+
* headless session has nobody to ask when one of them wants a person. The session's `result` record lists every
|
|
91
|
+
* call that was refused, by id; the ones this page allowed are the ones worth a line, because without it the
|
|
92
|
+
* dock said "allowed" and nothing happened. A tool that ran and failed is not in that list, and is not called
|
|
93
|
+
* a refusal here.
|
|
94
|
+
*
|
|
95
|
+
* @param denials `permission_denials` of a result record
|
|
96
|
+
* @param wasAllowed (toolUseId, toolName) => did this page allow it
|
|
97
|
+
* @param resultOf toolUseId => the result on screen for that call, if there is one
|
|
98
|
+
*/
|
|
99
|
+
export function refusals(denials, wasAllowed, resultOf = () => null) {
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const d of Array.isArray(denials) ? denials : []) {
|
|
102
|
+
if (typeof d?.tool_use_id !== 'string' || !wasAllowed(d.tool_use_id, d.tool_name ?? null)) continue;
|
|
103
|
+
const i = d.tool_input ?? {};
|
|
104
|
+
const what = i.command ?? i.file_path ?? i.description ?? null;
|
|
105
|
+
const reason = resultOf(d.tool_use_id)?.text?.split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? null;
|
|
106
|
+
// Who refused is in the result's own words. Another PreToolUse hook that blocked the call is reported by
|
|
107
|
+
// the harness as a hook error; anything else is the harness's own check. With no result on screen the line
|
|
108
|
+
// says only what is known: it was allowed here and did not run.
|
|
109
|
+
const by = reason == null ? 'it did not run'
|
|
110
|
+
: /^PreToolUse:\S* hook error/.test(reason) ? 'another gate blocked it' : 'Claude Code refused it';
|
|
111
|
+
out.push({
|
|
112
|
+
id: d.tool_use_id,
|
|
113
|
+
text: `Allowed here, but ${by}: ${d.tool_name ?? 'tool'}${what ? ` · ${String(what).slice(0, 120)}` : ''}`
|
|
114
|
+
+ (reason ? ` — ${reason.slice(0, 200)}` : ''),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The line that says a request is waiting, for the session a pane shows. Null when none is. */
|
|
121
|
+
export function reminderOf(waiting) {
|
|
122
|
+
const list = waiting ?? [];
|
|
123
|
+
if (!list.length) return null;
|
|
124
|
+
const who = list[0].agentType ?? 'This session';
|
|
125
|
+
const more = list.length - 1;
|
|
126
|
+
return `${who} is waiting for approval${more > 0 ? `, and ${more} more ${more === 1 ? 'request is' : 'requests are'}` : ''}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The strip above a conversation: what kind of session this is, in the order the design has it. */
|
|
130
|
+
export function headerOf(session, { readOnly = false, contextTokens = null } = {}) {
|
|
131
|
+
if (readOnly) return { badge: 'Read only', tone: 'none', parts: [] };
|
|
132
|
+
const parts = [];
|
|
133
|
+
if (session?.permissionMode) parts.push(`mode: ${session.permissionMode}`);
|
|
134
|
+
const facts = [];
|
|
135
|
+
if (typeof session?.turns === 'number') facts.push(`${session.turns} ${session.turns === 1 ? 'turn' : 'turns'}`);
|
|
136
|
+
if (contextTokens != null) facts.push(`${fmtK(contextTokens)} ctx`);
|
|
137
|
+
if (facts.length) parts.push(facts.join(' · '));
|
|
138
|
+
return {
|
|
139
|
+
badge: 'Started here',
|
|
140
|
+
tone: 'good',
|
|
141
|
+
parts,
|
|
142
|
+
// A session started here without the gate is running unguarded, and that is said in the strip itself.
|
|
143
|
+
ungated: session ? session.gated === false : false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fmtK(t) {
|
|
148
|
+
if (t < 1000) return `${t}`;
|
|
149
|
+
if (t < 1_000_000) return `${(t / 1000).toFixed(t < 10_000 ? 1 : 0)}k`;
|
|
150
|
+
return `${(t / 1_000_000).toFixed(2)}M`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Run `fn` at most once per arming.
|
|
155
|
+
*
|
|
156
|
+
* A guard that only refuses while a request is in flight lets a second click through the moment the first
|
|
157
|
+
* answer lands: two clicks, two sessions. This one is spent by its first call and stays spent — a later call
|
|
158
|
+
* gets the first call's answer — until `arm()` says a new attempt is meant. A call that failed re-arms itself,
|
|
159
|
+
* so a refusal can be retried without reopening anything.
|
|
160
|
+
*/
|
|
161
|
+
export function oneShot(fn) {
|
|
162
|
+
let spent = null;
|
|
163
|
+
const run = (...args) => {
|
|
164
|
+
if (spent) return spent;
|
|
165
|
+
spent = Promise.resolve().then(() => fn(...args)).then(
|
|
166
|
+
(out) => { if (out && out.ok === false) spent = null; return out; },
|
|
167
|
+
(err) => { spent = null; throw err; },
|
|
168
|
+
);
|
|
169
|
+
return spent;
|
|
170
|
+
};
|
|
171
|
+
run.arm = () => { spent = null; };
|
|
172
|
+
run.spent = () => spent !== null;
|
|
173
|
+
return run;
|
|
174
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// The approval dock: a strip under the canvas that exists only while a tool call is waiting.
|
|
2
|
+
//
|
|
3
|
+
// Not a dialog. It takes no focus and covers nothing, so the graph stays usable while a request waits — and the
|
|
4
|
+
// request is not forgotten either, because its clock is on screen and counting. What it says and when comes from
|
|
5
|
+
// approvals.js; this file draws it and hands the three answers back.
|
|
6
|
+
import { remaining, asker, allowSessionLabel, VERDICTS, OUTCOME_WORD } from './approvals.js';
|
|
7
|
+
|
|
8
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
9
|
+
const RING = 2 * Math.PI * 15; // the countdown ring's circumference, r = 15
|
|
10
|
+
// How long "Denied" or "Timed out — denied" stays up. Shorter when another request is waiting behind it: its
|
|
11
|
+
// clock is running while the word is on screen.
|
|
12
|
+
const FLASH_MS = { alone: 2600, queued: 1400 };
|
|
13
|
+
|
|
14
|
+
function mk(tag, cls, text) {
|
|
15
|
+
const n = document.createElement(tag);
|
|
16
|
+
if (cls) n.className = cls;
|
|
17
|
+
if (text != null) n.textContent = text;
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Dock {
|
|
22
|
+
/**
|
|
23
|
+
* @param root the element the dock lives in
|
|
24
|
+
* @param onDecide (item, verdict) => void — verdict is 'allow', 'always' or 'deny'
|
|
25
|
+
* @param onLocate (item) => void — show the agent that asked
|
|
26
|
+
* @param where (item) => the session's name when the request is not in the one on screen, else null
|
|
27
|
+
* @param now () => the server's clock, in ms
|
|
28
|
+
*/
|
|
29
|
+
constructor(root, { onDecide, onLocate, where, now } = {}) {
|
|
30
|
+
this.root = root;
|
|
31
|
+
this.onDecide = onDecide ?? (() => {});
|
|
32
|
+
this.onLocate = onLocate ?? (() => {});
|
|
33
|
+
this.where = where ?? (() => null);
|
|
34
|
+
this.now = now ?? (() => Date.now());
|
|
35
|
+
this.items = [];
|
|
36
|
+
this.at = 0;
|
|
37
|
+
this.open = false; // the command shown in full
|
|
38
|
+
this.busy = new Set(); // requests an answer has been sent for
|
|
39
|
+
this.flash = null; // { word, outcome, until }
|
|
40
|
+
this.flashTimer = null;
|
|
41
|
+
this.suppressed = false; // another surface is showing the requests with their answers
|
|
42
|
+
this.#build();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#build() {
|
|
46
|
+
this.root.classList.add('dock');
|
|
47
|
+
this.root.tabIndex = -1;
|
|
48
|
+
|
|
49
|
+
const timer = mk('div', 'dock-timer');
|
|
50
|
+
timer.setAttribute('role', 'timer');
|
|
51
|
+
const svg = document.createElementNS(SVG, 'svg');
|
|
52
|
+
svg.setAttribute('viewBox', '0 0 36 36');
|
|
53
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
54
|
+
const track = document.createElementNS(SVG, 'circle');
|
|
55
|
+
const ring = document.createElementNS(SVG, 'circle');
|
|
56
|
+
for (const c of [track, ring]) { c.setAttribute('cx', '18'); c.setAttribute('cy', '18'); c.setAttribute('r', '15'); }
|
|
57
|
+
track.setAttribute('class', 'dock-ring-track');
|
|
58
|
+
ring.setAttribute('class', 'dock-ring');
|
|
59
|
+
svg.append(track, ring);
|
|
60
|
+
const secs = mk('span', 'dock-secs');
|
|
61
|
+
timer.append(svg, secs);
|
|
62
|
+
|
|
63
|
+
const text = mk('div', 'dock-text');
|
|
64
|
+
const head = mk('div', 'dock-head');
|
|
65
|
+
const word = mk('span', 'dock-word', 'Needs you');
|
|
66
|
+
// Who asked is the way to them: it brings that agent's card into view.
|
|
67
|
+
const who = mk('button', 'dock-who');
|
|
68
|
+
who.type = 'button';
|
|
69
|
+
who.title = 'Show on the canvas';
|
|
70
|
+
who.addEventListener('click', () => { if (this.current) this.onLocate(this.current); });
|
|
71
|
+
const tool = mk('span', 'dock-tool');
|
|
72
|
+
const where = mk('span', 'dock-where');
|
|
73
|
+
head.append(word, who, tool, where);
|
|
74
|
+
// The command is a control: one line until it is asked for in full.
|
|
75
|
+
const cmd = mk('button', 'dock-cmd');
|
|
76
|
+
cmd.type = 'button';
|
|
77
|
+
cmd.addEventListener('click', () => { this.open = !this.open; this.paint(); });
|
|
78
|
+
const note = mk('div', 'dock-note');
|
|
79
|
+
text.append(head, cmd, note);
|
|
80
|
+
|
|
81
|
+
const nav = mk('div', 'dock-nav');
|
|
82
|
+
const prev = mk('button', 'btn icon sm ghost dock-step');
|
|
83
|
+
prev.type = 'button';
|
|
84
|
+
prev.setAttribute('aria-label', 'Previous request');
|
|
85
|
+
prev.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M10 4l-4 4 4 4"/></svg>';
|
|
86
|
+
const count = mk('span', 'dock-count');
|
|
87
|
+
const next = mk('button', 'btn icon sm ghost dock-step');
|
|
88
|
+
next.type = 'button';
|
|
89
|
+
next.setAttribute('aria-label', 'Next request');
|
|
90
|
+
next.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>';
|
|
91
|
+
prev.addEventListener('click', () => this.step(-1));
|
|
92
|
+
next.addEventListener('click', () => this.step(1));
|
|
93
|
+
nav.append(prev, count, next);
|
|
94
|
+
|
|
95
|
+
const acts = mk('div', 'dock-acts');
|
|
96
|
+
const deny = mk('button', 'btn sm', VERDICTS.deny.label);
|
|
97
|
+
const always = mk('button', 'btn sm');
|
|
98
|
+
const allow = mk('button', 'btn sm primary', VERDICTS.allow.label);
|
|
99
|
+
for (const b of [deny, always, allow]) b.type = 'button';
|
|
100
|
+
deny.addEventListener('click', () => this.decide('deny'));
|
|
101
|
+
always.addEventListener('click', () => this.decide('always'));
|
|
102
|
+
allow.addEventListener('click', () => this.decide('allow'));
|
|
103
|
+
acts.append(deny, always, allow);
|
|
104
|
+
|
|
105
|
+
// a / s / d, and only while the dock itself has the focus: a letter typed anywhere else is a letter.
|
|
106
|
+
this.root.addEventListener('keydown', (e) => {
|
|
107
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
108
|
+
const verdict = { a: 'allow', s: 'always', d: 'deny' }[e.key];
|
|
109
|
+
if (!verdict) return;
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
this.decide(verdict);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.parts = { timer, ring, secs, word, who, tool, where, cmd, note, nav, prev, next, count, acts, deny, always, allow };
|
|
115
|
+
this.root.replaceChildren(timer, text, nav, acts);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get current() { return this.items[this.at] ?? null; }
|
|
119
|
+
|
|
120
|
+
/** The queue changed. The request being looked at stays the one being looked at, if it is still waiting. */
|
|
121
|
+
render(items) {
|
|
122
|
+
const was = this.current?.key ?? null;
|
|
123
|
+
this.items = items ?? [];
|
|
124
|
+
const keep = this.items.findIndex((r) => r.key === was);
|
|
125
|
+
this.at = keep === -1 ? Math.min(this.at, Math.max(0, this.items.length - 1)) : keep;
|
|
126
|
+
if (keep === -1) this.open = false;
|
|
127
|
+
for (const key of [...this.busy]) if (!this.items.some((r) => r.key === key)) this.busy.delete(key);
|
|
128
|
+
this.paint();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
step(by) {
|
|
132
|
+
if (this.items.length < 2) return;
|
|
133
|
+
this.at = (this.at + by + this.items.length) % this.items.length;
|
|
134
|
+
this.open = false;
|
|
135
|
+
this.paint();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
decide(verdict) {
|
|
139
|
+
const item = this.current;
|
|
140
|
+
// While the dock is saying what became of the last request, the next one is not on screen yet, and a key
|
|
141
|
+
// pressed then must not answer something nobody has read.
|
|
142
|
+
if (!item || this.busy.has(item.key) || this.flashing) return;
|
|
143
|
+
this.busy.add(item.key);
|
|
144
|
+
this.paint();
|
|
145
|
+
this.onDecide(item, verdict);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** An answer could not be delivered: the buttons come back. */
|
|
149
|
+
release(key) { this.busy.delete(key); this.paint(); }
|
|
150
|
+
|
|
151
|
+
/** Say what became of a request for a moment: "Denied", "Timed out — denied". */
|
|
152
|
+
say(outcome, queued = false) {
|
|
153
|
+
const ms = queued ? FLASH_MS.queued : FLASH_MS.alone;
|
|
154
|
+
this.flash = { word: OUTCOME_WORD[outcome] ?? outcome, outcome, until: Date.now() + ms };
|
|
155
|
+
clearTimeout(this.flashTimer);
|
|
156
|
+
this.flashTimer = setTimeout(() => { this.flash = null; this.paint(); }, ms);
|
|
157
|
+
this.paint();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get flashing() { return Boolean(this.flash) && Date.now() < this.flash.until; }
|
|
161
|
+
|
|
162
|
+
/** The List view shows each request as a card with its own answers; the dock stands down while it does. */
|
|
163
|
+
setSuppressed(on) { this.suppressed = Boolean(on); this.paint(); }
|
|
164
|
+
|
|
165
|
+
/** Called once a second by the page, so the clock moves on its own. */
|
|
166
|
+
tick() { if (this.items.length || this.flash) this.paint(); }
|
|
167
|
+
|
|
168
|
+
paint() {
|
|
169
|
+
const p = this.parts;
|
|
170
|
+
const item = this.current;
|
|
171
|
+
const flashing = this.flashing;
|
|
172
|
+
this.root.hidden = this.suppressed || (!item && !flashing);
|
|
173
|
+
if (this.root.hidden) return;
|
|
174
|
+
|
|
175
|
+
// What became of the last request is said in the place it was, before the next one takes it.
|
|
176
|
+
for (const part of [p.timer, p.who, p.tool, p.where, p.cmd, p.note, p.nav, p.acts]) part.hidden = flashing;
|
|
177
|
+
if (flashing) {
|
|
178
|
+
this.root.dataset.state = this.flash.outcome;
|
|
179
|
+
p.word.textContent = this.flash.word;
|
|
180
|
+
this.root.setAttribute('aria-label', this.flash.word);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.root.dataset.state = 'waiting';
|
|
184
|
+
p.word.textContent = 'Needs you';
|
|
185
|
+
|
|
186
|
+
p.who.textContent = asker(item);
|
|
187
|
+
p.tool.textContent = item.toolName;
|
|
188
|
+
const where = this.where(item);
|
|
189
|
+
p.where.hidden = !where;
|
|
190
|
+
p.where.textContent = where ? `in ${where}` : '';
|
|
191
|
+
p.cmd.hidden = !item.detail;
|
|
192
|
+
p.cmd.textContent = item.detail ?? '';
|
|
193
|
+
p.cmd.classList.toggle('open', this.open);
|
|
194
|
+
p.cmd.setAttribute('aria-expanded', String(this.open));
|
|
195
|
+
p.cmd.title = this.open ? 'Show on one line' : 'Show the whole command';
|
|
196
|
+
|
|
197
|
+
const r = remaining(item, this.now());
|
|
198
|
+
if (r.known) {
|
|
199
|
+
p.ring.style.strokeDasharray = `${(r.fraction * RING).toFixed(1)} ${RING.toFixed(1)}`;
|
|
200
|
+
p.secs.textContent = `${r.left}s`;
|
|
201
|
+
p.timer.setAttribute('aria-label', `Auto-deny in ${r.left} seconds`);
|
|
202
|
+
p.note.textContent = `Auto-deny in ${r.left}s if nobody answers`;
|
|
203
|
+
} else {
|
|
204
|
+
// The server did not say how long the hook waits. No clock is better than an invented one.
|
|
205
|
+
p.ring.style.strokeDasharray = `0 ${RING.toFixed(1)}`;
|
|
206
|
+
p.secs.textContent = '?';
|
|
207
|
+
p.timer.setAttribute('aria-label', 'Time left not measured');
|
|
208
|
+
p.note.textContent = 'Time left not measured — it is denied if nobody answers';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
p.nav.hidden = this.items.length < 2;
|
|
212
|
+
p.count.textContent = `${this.at + 1} of ${this.items.length}`;
|
|
213
|
+
p.nav.setAttribute('aria-label', `Request ${this.at + 1} of ${this.items.length}`);
|
|
214
|
+
|
|
215
|
+
const sent = this.busy.has(item.key);
|
|
216
|
+
p.always.textContent = allowSessionLabel(item.toolName);
|
|
217
|
+
p.always.title = `Allow this call, and stop asking here about ${item.toolName} for the rest of the session. Later calls are not approved for Claude Code: it still applies its own checks to them.`;
|
|
218
|
+
for (const b of [p.deny, p.always, p.allow]) b.disabled = sent;
|
|
219
|
+
this.root.setAttribute('aria-label', `Waiting for approval: ${asker(item)} wants to run ${item.toolName}`);
|
|
220
|
+
}
|
|
221
|
+
}
|