nearly-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/STUDY.md +101 -0
- package/bin/nearly.mjs +108 -0
- package/package.json +39 -0
- package/scripts/attach.mjs +195 -0
- package/scripts/build-recap.mjs +655 -0
- package/scripts/hook.mjs +79 -0
- package/scripts/install-push-hook.mjs +87 -0
- package/scripts/post-recap.mjs +124 -0
- package/scripts/publish-pages.mjs +141 -0
- package/scripts/push-record.mjs +126 -0
- package/scripts/update-check.mjs +116 -0
- package/server/index.mjs +538 -0
- package/server/policy.mjs +61 -0
- package/ui/index.html +522 -0
- package/ui/recap.template.html +449 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The consent gradient: which tier a tool call lands in, and what "always" and
|
|
2
|
+
// "never" attach to.
|
|
3
|
+
//
|
|
4
|
+
// Kept apart from the server because this is the one piece where a mistake is
|
|
5
|
+
// silent and expensive. A rule key that is too broad turns one "always" into
|
|
6
|
+
// blanket permission for a whole class of commands; a never pattern that does
|
|
7
|
+
// not match means a destructive command reaches a human who approves it out of
|
|
8
|
+
// habit. Both are testable, so they are tested.
|
|
9
|
+
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
// Denied outright. These never reach a person, because a prompt is a chance to
|
|
13
|
+
// say yes and there is no version of these worth saying yes to in an agent's
|
|
14
|
+
// worktree.
|
|
15
|
+
export const NEVER_PATTERNS = [
|
|
16
|
+
/\brm\s+-rf?\b/,
|
|
17
|
+
/\bgit\s+push\b/,
|
|
18
|
+
/\bsudo\b/,
|
|
19
|
+
/\.env\b/,
|
|
20
|
+
/curl[^|]*\|\s*(ba)?sh/,
|
|
21
|
+
/\bchmod\s+777\b/,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Everything not listed falls through to "ask", so a tool nobody has thought
|
|
25
|
+
// about yet is held rather than allowed.
|
|
26
|
+
export const DEFAULT_TIER = {
|
|
27
|
+
Read: 'log', Glob: 'log', Grep: 'log', LS: 'log', WebSearch: 'log', TodoWrite: 'log',
|
|
28
|
+
WebFetch: 'ask', Bash: 'ask', Edit: 'ask', Write: 'ask', MultiEdit: 'ask',
|
|
29
|
+
NotebookEdit: 'ask', Task: 'ask',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// What a decision generalises to when you pick "always" or "never".
|
|
33
|
+
//
|
|
34
|
+
// For a shell command that is the first word, so allowing `git status` does not
|
|
35
|
+
// also allow `git push`. For a file write it is the extension, so allowing an
|
|
36
|
+
// edit to one .js file does not also allow edits to .env. Anything else is the
|
|
37
|
+
// tool itself.
|
|
38
|
+
export function ruleKey(hook) {
|
|
39
|
+
const t = hook.tool_name;
|
|
40
|
+
if (t === 'Bash') {
|
|
41
|
+
const first = String(hook.tool_input?.command || '').trim().split(/\s+/)[0] || '?';
|
|
42
|
+
return `Bash:${first}`;
|
|
43
|
+
}
|
|
44
|
+
if (t === 'Edit' || t === 'Write' || t === 'MultiEdit') {
|
|
45
|
+
const p = hook.tool_input?.file_path || '';
|
|
46
|
+
return `${t}:${path.extname(p) || '(no ext)'}`;
|
|
47
|
+
}
|
|
48
|
+
return t;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// never > learned rule > default for the tool > ask.
|
|
52
|
+
export function classify(hook, rules = new Map()) {
|
|
53
|
+
const t = hook.tool_name;
|
|
54
|
+
const cmd = String(hook.tool_input?.command || '');
|
|
55
|
+
if (t === 'Bash' && NEVER_PATTERNS.some((r) => r.test(cmd))) {
|
|
56
|
+
return { tier: 'never', reason: 'matches a never rule' };
|
|
57
|
+
}
|
|
58
|
+
const key = ruleKey(hook);
|
|
59
|
+
if (rules.has(key)) return { tier: rules.get(key), reason: `rule ${key}` };
|
|
60
|
+
return { tier: DEFAULT_TIER[t] ?? 'ask', reason: `default for ${t}` };
|
|
61
|
+
}
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Nearly</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
8
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
/* A dark tool that sits next to a terminal, but not a terminal. The ground
|
|
12
|
+
carries a slight blue bias so the neutrals read as chosen, not default. */
|
|
13
|
+
--bg: #0B0D11;
|
|
14
|
+
--panel: #13161C;
|
|
15
|
+
--panel-2: #191D25;
|
|
16
|
+
--panel-3: #20252F;
|
|
17
|
+
--rule: #242932;
|
|
18
|
+
--rule-soft: #1B1F27;
|
|
19
|
+
|
|
20
|
+
--ink: #E9ECF1;
|
|
21
|
+
--ink-2: #98A1AF;
|
|
22
|
+
--ink-3: #667080;
|
|
23
|
+
|
|
24
|
+
/* The accent is for interactive chrome only. Status never borrows it. */
|
|
25
|
+
--accent: #7C8CFF;
|
|
26
|
+
--accent-dim: #2A3050;
|
|
27
|
+
|
|
28
|
+
/* Semantic. "ask" is deliberately the loudest thing on the page: a held
|
|
29
|
+
call is the only thing here that is actually costing someone time. */
|
|
30
|
+
--ask: #FFC145;
|
|
31
|
+
--ask-dim: #2E2616;
|
|
32
|
+
--allow: #3DD68C;
|
|
33
|
+
--allow-dim: #10271D;
|
|
34
|
+
--deny: #FF7A7A;
|
|
35
|
+
--deny-dim: #2B1718;
|
|
36
|
+
|
|
37
|
+
--sans: "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
38
|
+
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
39
|
+
--r: 10px;
|
|
40
|
+
color-scheme: dark;
|
|
41
|
+
}
|
|
42
|
+
* { box-sizing: border-box; }
|
|
43
|
+
html, body { height: 100%; }
|
|
44
|
+
body {
|
|
45
|
+
margin: 0; background: var(--bg); color: var(--ink);
|
|
46
|
+
font: 14px/1.55 var(--sans); -webkit-font-smoothing: antialiased;
|
|
47
|
+
letter-spacing: -0.006em;
|
|
48
|
+
}
|
|
49
|
+
.mono { font-family: var(--mono); }
|
|
50
|
+
.lbl {
|
|
51
|
+
font-family: var(--mono); font-size: 10px; letter-spacing: .14em;
|
|
52
|
+
text-transform: uppercase; color: var(--ink-3); font-weight: 500;
|
|
53
|
+
}
|
|
54
|
+
kbd {
|
|
55
|
+
font-family: var(--mono); font-size: 10px; line-height: 1; padding: 3px 5px;
|
|
56
|
+
border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: 4px;
|
|
57
|
+
color: var(--ink-3); background: var(--panel-2); margin-left: 6px; white-space: nowrap;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ---------- top bar ---------- */
|
|
61
|
+
header {
|
|
62
|
+
display: flex; align-items: center; gap: 14px; height: 52px; padding: 0 18px;
|
|
63
|
+
border-bottom: 1px solid var(--rule); background: var(--panel); position: sticky; top: 0; z-index: 10;
|
|
64
|
+
}
|
|
65
|
+
.brand { display: flex; align-items: center; gap: 9px; }
|
|
66
|
+
.mark {
|
|
67
|
+
width: 20px; height: 20px; border-radius: 6px; display: grid; place-items: center;
|
|
68
|
+
background: linear-gradient(145deg, var(--accent), #5866D4); flex: none;
|
|
69
|
+
}
|
|
70
|
+
.mark i { width: 6px; height: 6px; border-radius: 50%; background: #0B0D11; display: block; }
|
|
71
|
+
h1 { font-size: 14.5px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
|
72
|
+
.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-3); }
|
|
73
|
+
.conn i { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); display: block; }
|
|
74
|
+
.conn[data-on="live"] i { background: var(--allow); box-shadow: 0 0 0 3px var(--allow-dim); }
|
|
75
|
+
.conn[data-on="live"] { color: var(--ink-2); }
|
|
76
|
+
.quota { margin-left: auto; display: flex; gap: 16px; font-size: 12px; color: var(--ink-3); }
|
|
77
|
+
.quota b { color: var(--ink-2); font-weight: 600; font-family: var(--mono); font-size: 11.5px; }
|
|
78
|
+
|
|
79
|
+
/* ---------- layout ---------- */
|
|
80
|
+
main {
|
|
81
|
+
display: grid; grid-template-columns: 272px minmax(0, 1fr) 340px;
|
|
82
|
+
height: calc(100vh - 52px);
|
|
83
|
+
}
|
|
84
|
+
.col { overflow-y: auto; border-right: 1px solid var(--rule); display: flex; flex-direction: column; }
|
|
85
|
+
.col:last-child { border-right: 0; }
|
|
86
|
+
.col-h {
|
|
87
|
+
display: flex; align-items: center; gap: 8px; padding: 15px 16px 9px;
|
|
88
|
+
position: sticky; top: 0; background: var(--bg); z-index: 2;
|
|
89
|
+
}
|
|
90
|
+
.col-h .count {
|
|
91
|
+
margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-3);
|
|
92
|
+
background: var(--panel-2); border-radius: 20px; padding: 2px 8px;
|
|
93
|
+
}
|
|
94
|
+
.col-h .count[data-hot="1"] { color: var(--ask); background: var(--ask-dim); }
|
|
95
|
+
|
|
96
|
+
/* ---------- controls ---------- */
|
|
97
|
+
button {
|
|
98
|
+
font: inherit; font-size: 12.5px; font-weight: 600; letter-spacing: -0.01em;
|
|
99
|
+
padding: 7px 12px; border-radius: 7px; border: 1px solid var(--rule);
|
|
100
|
+
background: var(--panel-2); color: var(--ink); cursor: pointer;
|
|
101
|
+
display: inline-flex; align-items: center; transition: background .12s, border-color .12s;
|
|
102
|
+
}
|
|
103
|
+
button:hover { background: var(--panel-3); border-color: var(--ink-3); }
|
|
104
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
105
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
106
|
+
button[data-v="primary"] { background: var(--accent); border-color: var(--accent); color: #0B0D11; }
|
|
107
|
+
button[data-v="primary"]:hover { background: #8F9CFF; }
|
|
108
|
+
button[data-v="allow"] { color: var(--allow); border-color: color-mix(in srgb, var(--allow) 34%, transparent); }
|
|
109
|
+
button[data-v="allow"]:hover { background: var(--allow-dim); border-color: var(--allow); }
|
|
110
|
+
button[data-v="deny"] { color: var(--deny); border-color: color-mix(in srgb, var(--deny) 34%, transparent); }
|
|
111
|
+
button[data-v="deny"]:hover { background: var(--deny-dim); border-color: var(--deny); }
|
|
112
|
+
button[data-v="ghost"] { background: transparent; color: var(--ink-2); border-color: transparent; padding-inline: 8px; }
|
|
113
|
+
button[data-v="ghost"]:hover { background: var(--panel-2); color: var(--ink); }
|
|
114
|
+
input, textarea {
|
|
115
|
+
font: inherit; font-size: 13px; width: 100%; background: var(--bg); color: var(--ink);
|
|
116
|
+
border: 1px solid var(--rule); border-radius: 7px; padding: 8px 10px;
|
|
117
|
+
}
|
|
118
|
+
input::placeholder, textarea::placeholder { color: var(--ink-3); }
|
|
119
|
+
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
|
120
|
+
textarea { resize: vertical; min-height: 66px; line-height: 1.5; }
|
|
121
|
+
|
|
122
|
+
/* ---------- fleet ---------- */
|
|
123
|
+
.new { margin: 0 12px 14px; display: grid; gap: 7px; }
|
|
124
|
+
.fleet { padding: 0 12px 18px; display: grid; gap: 8px; }
|
|
125
|
+
.agent {
|
|
126
|
+
background: var(--panel); border: 1px solid var(--rule); border-radius: var(--r);
|
|
127
|
+
padding: 11px 13px; display: grid; gap: 7px; position: relative; overflow: hidden;
|
|
128
|
+
}
|
|
129
|
+
.agent[data-state="waiting"] { border-color: color-mix(in srgb, var(--ask) 40%, transparent); }
|
|
130
|
+
.agent[data-state="waiting"]::before {
|
|
131
|
+
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: var(--ask);
|
|
132
|
+
}
|
|
133
|
+
.agent .top { display: flex; align-items: center; gap: 8px; }
|
|
134
|
+
.agent .nm { font-weight: 600; font-size: 13.5px; letter-spacing: -0.01em; }
|
|
135
|
+
.state {
|
|
136
|
+
display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono);
|
|
137
|
+
font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3);
|
|
138
|
+
}
|
|
139
|
+
.state i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; display: block; }
|
|
140
|
+
.state[data-s="working"] { color: var(--accent); }
|
|
141
|
+
.state[data-s="waiting"] { color: var(--ask); }
|
|
142
|
+
.state[data-s="idle"] { color: var(--allow); }
|
|
143
|
+
.tag {
|
|
144
|
+
font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
|
|
145
|
+
color: var(--ink-3); border: 1px solid var(--rule); border-radius: 4px; padding: 1px 5px;
|
|
146
|
+
}
|
|
147
|
+
.agent .said { color: var(--ink-2); font-size: 12.5px; line-height: 1.45; max-height: 3em; overflow: hidden; }
|
|
148
|
+
.agent .meta { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); display: flex; gap: 10px; flex-wrap: wrap; }
|
|
149
|
+
.agent .acts { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; }
|
|
150
|
+
.agent .acts input { flex: 1 1 110px; min-width: 0; font-size: 12px; padding: 5px 8px; }
|
|
151
|
+
.agent .acts button { font-size: 11.5px; padding: 5px 9px; }
|
|
152
|
+
|
|
153
|
+
/* ---------- the ask: the point of the whole page ---------- */
|
|
154
|
+
.asks { padding: 0 18px 24px; display: grid; gap: 14px; }
|
|
155
|
+
.ask {
|
|
156
|
+
background: var(--panel); border: 1px solid var(--rule); border-radius: 14px;
|
|
157
|
+
display: grid; overflow: hidden; scroll-margin-top: 70px;
|
|
158
|
+
}
|
|
159
|
+
.ask.focus { border-color: color-mix(in srgb, var(--ask) 55%, transparent); box-shadow: 0 0 0 3px var(--ask-dim); }
|
|
160
|
+
.ask-h { display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-bottom: 1px solid var(--rule-soft); }
|
|
161
|
+
.ask-h .tool { font-weight: 700; font-size: 15px; letter-spacing: -0.02em; }
|
|
162
|
+
.ask-h .who { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
|
163
|
+
.ask-h .clock {
|
|
164
|
+
margin-left: auto; font-family: var(--mono); font-size: 12px; font-weight: 500;
|
|
165
|
+
color: var(--ink-2); font-variant-numeric: tabular-nums;
|
|
166
|
+
}
|
|
167
|
+
.ask-h .clock[data-urgent="1"] { color: var(--ask); }
|
|
168
|
+
.ask-h .clock[data-urgent="2"] { color: var(--deny); }
|
|
169
|
+
.ask pre {
|
|
170
|
+
margin: 0; padding: 14px 16px; background: var(--bg);
|
|
171
|
+
font: 13px/1.6 var(--mono); white-space: pre-wrap; word-break: break-word;
|
|
172
|
+
max-height: 230px; overflow: auto; color: var(--ink);
|
|
173
|
+
}
|
|
174
|
+
.blast {
|
|
175
|
+
display: flex; gap: 9px; padding: 11px 16px; font-size: 12.5px; color: var(--ink-2);
|
|
176
|
+
border-top: 1px solid var(--rule-soft); line-height: 1.5;
|
|
177
|
+
}
|
|
178
|
+
.blast b { color: var(--ink); font-weight: 600; }
|
|
179
|
+
.blast .ic { color: var(--ask); flex: none; font-size: 13px; line-height: 1.35; }
|
|
180
|
+
.btns { display: flex; gap: 7px; flex-wrap: wrap; padding: 12px 16px; border-top: 1px solid var(--rule-soft); }
|
|
181
|
+
/* The deadline is real: nobody answering means denied. Show it running out. */
|
|
182
|
+
.deadline { padding: 0 16px 13px; display: grid; gap: 5px; }
|
|
183
|
+
.deadline .bar { height: 3px; border-radius: 2px; background: var(--panel-3); overflow: hidden; }
|
|
184
|
+
.deadline .bar i { display: block; height: 100%; background: var(--ask); transition: width .9s linear; }
|
|
185
|
+
.deadline[data-urgent="2"] .bar i { background: var(--deny); }
|
|
186
|
+
.deadline .cap { font-size: 11px; color: var(--ink-3); }
|
|
187
|
+
|
|
188
|
+
.empty { padding: 30px 18px; color: var(--ink-3); font-size: 13px; max-width: 46ch; line-height: 1.65; }
|
|
189
|
+
.empty b { color: var(--ink-2); font-weight: 600; display: block; margin-bottom: 5px; font-size: 14px; }
|
|
190
|
+
.empty .keys { margin-top: 16px; display: grid; gap: 7px; font-size: 11.5px; }
|
|
191
|
+
.empty .keys kbd:first-child { margin-left: 0; }
|
|
192
|
+
.empty .keys span { display: flex; align-items: center; gap: 2px; }
|
|
193
|
+
.empty .keys span > :last-child { margin-left: 8px; }
|
|
194
|
+
|
|
195
|
+
.rules { padding: 0 18px 20px; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
196
|
+
.rules span {
|
|
197
|
+
font-family: var(--mono); font-size: 11px; color: var(--ink-2);
|
|
198
|
+
border: 1px solid var(--rule); border-radius: 5px; padding: 3px 8px; background: var(--panel);
|
|
199
|
+
}
|
|
200
|
+
.rules span em { font-style: normal; color: var(--ink-3); }
|
|
201
|
+
.rules .none { color: var(--ink-3); border-style: dashed; }
|
|
202
|
+
|
|
203
|
+
/* ---------- log ---------- */
|
|
204
|
+
.log { padding: 0 12px 20px; display: grid; gap: 1px; font-family: var(--mono); font-size: 11px; }
|
|
205
|
+
.ev { display: grid; grid-template-columns: 46px 1fr; gap: 9px; padding: 5px 7px; border-radius: 6px; color: var(--ink-2); }
|
|
206
|
+
.ev:hover { background: var(--panel); }
|
|
207
|
+
.ev .t { color: var(--ink-3); font-variant-numeric: tabular-nums; }
|
|
208
|
+
.ev .b { min-width: 0; display: grid; gap: 2px; }
|
|
209
|
+
.ev .k { font-size: 9.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }
|
|
210
|
+
.ev .v { white-space: pre-wrap; word-break: break-word; max-height: 5.4em; overflow: hidden; line-height: 1.45; }
|
|
211
|
+
.ev.decision .k { color: var(--allow); }
|
|
212
|
+
.ev.decision.deny .k { color: var(--deny); }
|
|
213
|
+
.ev.ask .k { color: var(--ask); }
|
|
214
|
+
.ev.tool_use .k { color: var(--accent); }
|
|
215
|
+
.ev.text .v { color: var(--ink); }
|
|
216
|
+
.ev.result { border-top: 1px solid var(--rule-soft); margin-top: 5px; padding-top: 7px; }
|
|
217
|
+
.ev.recap .k { color: var(--accent); }
|
|
218
|
+
.ev.stderr .k, .ev.api_error .k, .ev.checkpoint_error .k, .ev.recap_error .k { color: var(--deny); }
|
|
219
|
+
|
|
220
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
221
|
+
.ask { animation: rise .2s cubic-bezier(.2,.7,.3,1); }
|
|
222
|
+
@keyframes rise { from { transform: translateY(-6px); opacity: 0; } }
|
|
223
|
+
}
|
|
224
|
+
@media (max-width: 1060px) {
|
|
225
|
+
main { grid-template-columns: 1fr; height: auto; }
|
|
226
|
+
.col { border-right: 0; border-bottom: 1px solid var(--rule); }
|
|
227
|
+
.col-h { position: static; }
|
|
228
|
+
}
|
|
229
|
+
</style>
|
|
230
|
+
</head>
|
|
231
|
+
<body>
|
|
232
|
+
<header>
|
|
233
|
+
<div class="brand"><span class="mark"><i></i></span><h1>Nearly</h1></div>
|
|
234
|
+
<span class="conn" id="conn"><i></i><span id="connText">connecting</span></span>
|
|
235
|
+
<div class="quota" id="quota"></div>
|
|
236
|
+
</header>
|
|
237
|
+
|
|
238
|
+
<main>
|
|
239
|
+
<div class="col">
|
|
240
|
+
<div class="col-h"><span class="lbl">Agents</span><span class="count" id="fleetCount">0</span></div>
|
|
241
|
+
<form class="new" id="newForm">
|
|
242
|
+
<input name="name" placeholder="Name, e.g. docs" required autocomplete="off">
|
|
243
|
+
<textarea name="prompt" placeholder="What should this agent do in the workspace?" required></textarea>
|
|
244
|
+
<button type="submit" data-v="primary">Start agent</button>
|
|
245
|
+
</form>
|
|
246
|
+
<div class="fleet" id="sessions"></div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div class="col">
|
|
250
|
+
<div class="col-h"><span class="lbl">Needs you</span><span class="count" id="askCount">0</span></div>
|
|
251
|
+
<div class="asks" id="asks"></div>
|
|
252
|
+
<div class="col-h"><span class="lbl">Rules learned this run</span></div>
|
|
253
|
+
<div class="rules" id="rules"></div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div class="col">
|
|
257
|
+
<div class="col-h"><span class="lbl">Activity</span></div>
|
|
258
|
+
<div class="log" id="log"></div>
|
|
259
|
+
</div>
|
|
260
|
+
</main>
|
|
261
|
+
|
|
262
|
+
<script>
|
|
263
|
+
const S = { sessions: new Map(), rules: {}, defaults: {}, askTimeoutMs: 120000 };
|
|
264
|
+
const $ = (id) => document.getElementById(id);
|
|
265
|
+
const fmtT = (ms) => new Date(ms).toLocaleTimeString([], { hour12: false });
|
|
266
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
|
|
267
|
+
const post = (p, b) => fetch(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }).then((r) => r.json());
|
|
268
|
+
|
|
269
|
+
let focusIdx = 0; // which ask the keyboard is aimed at
|
|
270
|
+
let currentAsks = [];
|
|
271
|
+
|
|
272
|
+
function prettyInput(tool, input) {
|
|
273
|
+
if (!input) return '';
|
|
274
|
+
if (tool === 'Bash') return input.command + (input.description ? `\n# ${input.description}` : '');
|
|
275
|
+
if (tool === 'Edit') return `${input.file_path}\n--- old\n${input.old_string}\n+++ new\n${input.new_string}`;
|
|
276
|
+
if (tool === 'Write') return `${input.file_path}\n${(input.content || '').slice(0, 800)}`;
|
|
277
|
+
if (tool === 'Read' || tool === 'Glob' || tool === 'Grep') return JSON.stringify(input);
|
|
278
|
+
return JSON.stringify(input, null, 2);
|
|
279
|
+
}
|
|
280
|
+
function blast(tool) {
|
|
281
|
+
if (tool === 'Bash') return '<b>Runs a shell command</b> inside this agent\'s worktree. Reversible unless it touches the network or files outside it.';
|
|
282
|
+
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return '<b>Changes a file</b> in the worktree. Reversible with Undo.';
|
|
283
|
+
if (tool === 'WebFetch') return '<b>Reads an external URL.</b> Whatever it returns may try to instruct the agent.';
|
|
284
|
+
if (tool === 'Task') return '<b>Spawns a subagent</b> with its own tool calls, each gated here.';
|
|
285
|
+
return 'The default tier for this tool is “ask”.';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function renderSessions() {
|
|
289
|
+
const el = $('sessions');
|
|
290
|
+
el.innerHTML = '';
|
|
291
|
+
$('fleetCount').textContent = S.sessions.size;
|
|
292
|
+
for (const s of S.sessions.values()) {
|
|
293
|
+
const d = document.createElement('div');
|
|
294
|
+
d.className = 'agent';
|
|
295
|
+
d.dataset.state = s.state || '';
|
|
296
|
+
const u = s.usage ? `in ${s.usage.in ?? 0} · cache ${s.usage.cacheRead ?? 0} · out ${s.usage.out ?? 0}` : '';
|
|
297
|
+
d.innerHTML = `
|
|
298
|
+
<div class="top">
|
|
299
|
+
<span class="nm">${esc(s.name)}</span>
|
|
300
|
+
<span class="state" data-s="${esc(s.state || '')}"><i></i>${esc(s.state || '')}</span>
|
|
301
|
+
${s.attached ? '<span class="tag" title="Started by Claude Code in your own repo">attached</span>' : ''}
|
|
302
|
+
${s.currentTool ? `<span class="tag">${esc(s.currentTool)}</span>` : ''}
|
|
303
|
+
</div>
|
|
304
|
+
${s.lastText ? `<div class="said">${esc(s.lastText)}</div>` : ''}
|
|
305
|
+
<div class="meta"><span>${s.turns || 0} turns</span>${s.branch ? `<span>${esc(s.branch)}</span>` : ''}${u ? `<span>${u}</span>` : ''}</div>
|
|
306
|
+
<div class="acts">${s.attached ? '' : `
|
|
307
|
+
<input placeholder="Follow-up" data-msg="${s.id}" autocomplete="off">
|
|
308
|
+
<button data-send="${s.id}">Send</button>
|
|
309
|
+
<button data-v="ghost" data-undo="${s.id}" title="git reset --hard HEAD~1 in this worktree">Undo</button>`}
|
|
310
|
+
<button data-v="ghost" data-stop="${s.id}">${s.attached ? 'Detach' : 'Stop'}</button>
|
|
311
|
+
<button data-v="ghost" data-recap="${s.id}" title="Build the session record">Record</button>
|
|
312
|
+
<button data-v="ghost" data-post="${s.id}" title="Comment the record on this branch's pull request">Post</button>
|
|
313
|
+
</div>`;
|
|
314
|
+
el.appendChild(d);
|
|
315
|
+
}
|
|
316
|
+
if (!S.sessions.size) el.innerHTML = '<div class="empty">No agents yet. Start one above and it runs on your Claude subscription, in its own git worktree.</div>';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function renderAsks() {
|
|
320
|
+
const el = $('asks');
|
|
321
|
+
const asks = [];
|
|
322
|
+
for (const s of S.sessions.values()) for (const p of s.pending || []) asks.push({ ...p, sname: s.name });
|
|
323
|
+
// Triage by blast radius first: a shell command or a fetch can reach further
|
|
324
|
+
// than an edit, so it should be the one you are looking at.
|
|
325
|
+
const rank = (p) => (p.tool === 'Bash' || p.tool === 'WebFetch' || p.tool === 'Task') ? 0 : 1;
|
|
326
|
+
asks.sort((a, b) => rank(a) - rank(b) || a.at - b.at);
|
|
327
|
+
currentAsks = asks;
|
|
328
|
+
focusIdx = Math.min(focusIdx, Math.max(0, asks.length - 1));
|
|
329
|
+
|
|
330
|
+
$('askCount').textContent = asks.length;
|
|
331
|
+
$('askCount').dataset.hot = asks.length ? '1' : '0';
|
|
332
|
+
document.title = asks.length ? `(${asks.length}) Nearly` : 'Nearly';
|
|
333
|
+
|
|
334
|
+
if (!asks.length) {
|
|
335
|
+
el.innerHTML = `<div class="empty"><b>Nothing is waiting.</b>
|
|
336
|
+
Tools on the “do and log” tier run without asking and leave a receipt. You are only
|
|
337
|
+
interrupted for things that change something or reach outside the worktree.
|
|
338
|
+
<div class="keys">
|
|
339
|
+
<span><kbd>A</kbd><kbd>⇧A</kbd> allow once, or for the rest of the run</span>
|
|
340
|
+
<span><kbd>D</kbd><kbd>⇧D</kbd> deny once, or never again</span>
|
|
341
|
+
<span><kbd>J</kbd><kbd>K</kbd> move between requests</span>
|
|
342
|
+
</div></div>`;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
el.innerHTML = asks.map((p, i) => `
|
|
347
|
+
<div class="ask${i === focusIdx ? ' focus' : ''}" data-i="${i}" data-id="${p.id}" data-session="${p.session}">
|
|
348
|
+
<div class="ask-h">
|
|
349
|
+
<span class="tool">${esc(p.tool)}</span>
|
|
350
|
+
<span class="who">${esc(p.sname)}</span>
|
|
351
|
+
<span class="tag" title="Always and Never attach to this key">${esc(p.key || p.tool)}</span>
|
|
352
|
+
<span class="clock" data-wait="${p.at}"></span>
|
|
353
|
+
</div>
|
|
354
|
+
<pre>${esc(prettyInput(p.tool, p.input))}</pre>
|
|
355
|
+
<div class="blast"><span class="ic">▲</span><span>${blast(p.tool)}</span></div>
|
|
356
|
+
<div class="btns">
|
|
357
|
+
<button data-v="allow" data-decide="allow" data-scope="once">Allow once<kbd>A</kbd></button>
|
|
358
|
+
<button data-v="allow" data-decide="allow" data-scope="always" title="Allow every ${esc(p.key || p.tool)} for the rest of this run">Always<kbd>⇧A</kbd></button>
|
|
359
|
+
<button data-v="deny" data-decide="deny" data-scope="once">Deny<kbd>D</kbd></button>
|
|
360
|
+
<button data-v="deny" data-decide="deny" data-scope="always" title="Never allow ${esc(p.key || p.tool)} again this run">Never<kbd>⇧D</kbd></button>
|
|
361
|
+
</div>
|
|
362
|
+
<div class="deadline" data-wait-bar="${p.at}">
|
|
363
|
+
<div class="bar"><i></i></div>
|
|
364
|
+
<div class="cap"></div>
|
|
365
|
+
</div>
|
|
366
|
+
</div>`).join('');
|
|
367
|
+
tickWaits();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function decide(ask, decision, scope) {
|
|
371
|
+
if (!ask) return;
|
|
372
|
+
post('/decide', { session: ask.session, id: ask.id, decision, scope });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderRules() {
|
|
376
|
+
const el = $('rules');
|
|
377
|
+
const entries = Object.entries(S.rules);
|
|
378
|
+
el.innerHTML = entries.length
|
|
379
|
+
? entries.map(([k, v]) => `<span>${esc(k)} <em>→ ${esc(v)}</em></span>`).join('')
|
|
380
|
+
: '<span class="none">None yet. “Always” and “Never” create them.</span>';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderQuota() {
|
|
384
|
+
let w = null;
|
|
385
|
+
for (const s of S.sessions.values()) if (s.rateLimit) w = s.rateLimit;
|
|
386
|
+
if (!w) { $('quota').textContent = ''; return; }
|
|
387
|
+
const pct = (x) => Math.round((x?.utilization ?? 0) * 100) + '%';
|
|
388
|
+
$('quota').innerHTML = `<span>5-hour <b>${pct(w.five_hour)}</b></span><span>7-day <b>${pct(w.seven_day)}</b></span>`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function logEvent(ev) {
|
|
392
|
+
if (['session-state', 'snapshot', 'rules', 'rate_limit', 'hook', 'post_tool'].includes(ev.type)) return;
|
|
393
|
+
const s = S.sessions.get(ev.session);
|
|
394
|
+
const d = document.createElement('div');
|
|
395
|
+
d.className = `ev ${ev.type} ${ev.decision || ''}`;
|
|
396
|
+
let v = '';
|
|
397
|
+
switch (ev.type) {
|
|
398
|
+
case 'ask': v = `${ev.tool} ${prettyInput(ev.tool, ev.input).split('\n')[0]}`; break;
|
|
399
|
+
case 'decision': v = `${ev.decision} · ${ev.tool} · ${ev.why}${ev.waitedMs ? ` · waited ${(ev.waitedMs / 1000).toFixed(1)}s` : ''}`; break;
|
|
400
|
+
case 'tool_use': v = `${ev.tool} ${prettyInput(ev.tool, ev.input).split('\n')[0]}`; break;
|
|
401
|
+
case 'tool_result': v = (ev.is_error ? 'ERROR ' : '') + ev.content; break;
|
|
402
|
+
case 'text': v = ev.text; break;
|
|
403
|
+
case 'prompt': v = ev.text; break;
|
|
404
|
+
case 'result': v = `${ev.subtype} · ${ev.num_turns} turns · ${(ev.duration_ms / 1000).toFixed(0)}s · ${ev.denials?.length ?? 0} denial(s)\n${ev.text}`; break;
|
|
405
|
+
case 'checkpoint': v = `${ev.sha} ${ev.msg}`; break;
|
|
406
|
+
case 'turn_diff': v = `turn ${ev.turn} · ${(ev.stat || []).length} file(s) changed`; break;
|
|
407
|
+
case 'undo': v = `${ev.from} → ${ev.to}`; break;
|
|
408
|
+
case 'recap': v = `${ev.href} · ${ev.log}`; break;
|
|
409
|
+
case 'recap_error': v = ev.error; break;
|
|
410
|
+
case 'init': v = `model ${ev.model} · auth ${ev.apiKeySource}`; break;
|
|
411
|
+
case 'session': v = `${ev.subtype} ${ev.name || ''} ${ev.branch || ''} ${ev.code ?? ''}`; break;
|
|
412
|
+
default: v = ev.text || ev.detail || ev.error || JSON.stringify(ev).slice(0, 300);
|
|
413
|
+
}
|
|
414
|
+
d.innerHTML = `<span class="t">${fmtT(ev.at)}</span><span class="b"><span class="k">${esc(s ? s.name : '')} ${esc(ev.type)}</span><span class="v">${esc(v)}</span></span>`;
|
|
415
|
+
const log = $('log');
|
|
416
|
+
log.appendChild(d);
|
|
417
|
+
while (log.children.length > 400) log.removeChild(log.firstChild);
|
|
418
|
+
log.parentElement.scrollTop = log.parentElement.scrollHeight;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function upsert(ev) {
|
|
422
|
+
let s = S.sessions.get(ev.session);
|
|
423
|
+
if (!s) { s = { id: ev.session, name: ev.name || 'agent', pending: [], turns: 0 }; S.sessions.set(ev.session, s); }
|
|
424
|
+
return s;
|
|
425
|
+
}
|
|
426
|
+
function handle(ev) {
|
|
427
|
+
if (ev.type === 'snapshot') {
|
|
428
|
+
S.sessions.clear();
|
|
429
|
+
for (const s of ev.sessions) S.sessions.set(s.id, s);
|
|
430
|
+
S.rules = ev.rules; S.defaults = ev.defaults;
|
|
431
|
+
if (ev.askTimeoutMs) S.askTimeoutMs = ev.askTimeoutMs;
|
|
432
|
+
renderSessions(); renderAsks(); renderRules(); renderQuota();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (ev.type === 'rules') { S.rules = ev.rules; renderRules(); return; }
|
|
436
|
+
const s = upsert(ev);
|
|
437
|
+
if (ev.type === 'session' && ev.subtype === 'created') { s.name = ev.name; s.branch = ev.branch; s.attached = !!ev.attached; s.state = 'starting'; s.pending = []; }
|
|
438
|
+
if (ev.type === 'session-state') Object.assign(s, { state: ev.state, currentTool: ev.currentTool, turns: ev.turns ?? s.turns, usage: ev.usage ?? s.usage, rateLimit: ev.rateLimit ?? s.rateLimit, lastText: ev.lastText ?? s.lastText });
|
|
439
|
+
if (ev.type === 'ask') { s.pending = [...(s.pending || []).filter((p) => p.id !== ev.id), ev]; s.state = 'waiting'; }
|
|
440
|
+
if (ev.type === 'decision') { s.pending = (s.pending || []).filter((p) => p.id !== ev.id); }
|
|
441
|
+
if (ev.type === 'result') { s.turns = (s.turns || 0) + 1; s.state = 'idle'; }
|
|
442
|
+
if (ev.type === 'session' && ev.subtype === 'exited') { s.state = 'exited'; s.pending = []; }
|
|
443
|
+
renderSessions(); renderAsks(); renderQuota(); logEvent(ev);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function connect() {
|
|
447
|
+
const es = new EventSource('/events');
|
|
448
|
+
es.onopen = () => { $('conn').dataset.on = 'live'; $('connText').textContent = 'live'; };
|
|
449
|
+
es.onerror = () => { $('conn').dataset.on = ''; $('connText').textContent = 'reconnecting'; };
|
|
450
|
+
es.onmessage = (m) => handle(JSON.parse(m.data));
|
|
451
|
+
}
|
|
452
|
+
connect();
|
|
453
|
+
|
|
454
|
+
$('newForm').onsubmit = async (e) => {
|
|
455
|
+
e.preventDefault();
|
|
456
|
+
const f = new FormData(e.target);
|
|
457
|
+
const r = await post('/sessions', { name: f.get('name'), prompt: f.get('prompt') });
|
|
458
|
+
if (r.error) alert(r.error); else e.target.reset();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
document.body.addEventListener('click', async (e) => {
|
|
462
|
+
const b = e.target.closest('button');
|
|
463
|
+
if (!b) return;
|
|
464
|
+
if (b.dataset.decide) {
|
|
465
|
+
const card = b.closest('.ask');
|
|
466
|
+
post('/decide', { session: card.dataset.session, id: card.dataset.id, decision: b.dataset.decide, scope: b.dataset.scope });
|
|
467
|
+
}
|
|
468
|
+
if (b.dataset.send) { const i = document.querySelector(`[data-msg="${b.dataset.send}"]`); if (i.value.trim()) { await post('/message', { session: b.dataset.send, text: i.value }); i.value = ''; } }
|
|
469
|
+
if (b.dataset.stop) await post('/stop', { session: b.dataset.stop });
|
|
470
|
+
if (b.dataset.undo) { const r = await post('/undo', { session: b.dataset.undo }); if (r.error) alert(r.error); }
|
|
471
|
+
if (b.dataset.post) {
|
|
472
|
+
if (!confirm('Post the record as a comment on the open pull request for this branch, using your gh login?')) return;
|
|
473
|
+
b.disabled = true; b.textContent = 'Posting…';
|
|
474
|
+
const r = await post('/post-recap', { session: b.dataset.post });
|
|
475
|
+
b.disabled = false; b.textContent = 'Post';
|
|
476
|
+
alert(r.error ? r.error : `Posted: ${r.url}`);
|
|
477
|
+
}
|
|
478
|
+
if (b.dataset.recap) {
|
|
479
|
+
b.disabled = true; b.textContent = 'Building…';
|
|
480
|
+
const r = await post('/recap', { session: b.dataset.recap });
|
|
481
|
+
b.disabled = false; b.textContent = 'Record';
|
|
482
|
+
if (r.error) alert(r.error); else window.open(r.href, '_blank');
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Keyboard first. This is a tool you sit in front of while an agent is blocked
|
|
487
|
+
// on you, so answering should never require finding a button with a mouse.
|
|
488
|
+
document.addEventListener('keydown', (e) => {
|
|
489
|
+
if (e.target.matches('input, textarea') || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
490
|
+
if (!currentAsks.length) return;
|
|
491
|
+
const k = e.key.toLowerCase();
|
|
492
|
+
if (k === 'j' || e.key === 'ArrowDown') { focusIdx = Math.min(currentAsks.length - 1, focusIdx + 1); renderAsks(); document.querySelector('.ask.focus')?.scrollIntoView({ block: 'nearest' }); e.preventDefault(); return; }
|
|
493
|
+
if (k === 'k' || e.key === 'ArrowUp') { focusIdx = Math.max(0, focusIdx - 1); renderAsks(); document.querySelector('.ask.focus')?.scrollIntoView({ block: 'nearest' }); e.preventDefault(); return; }
|
|
494
|
+
const ask = currentAsks[focusIdx];
|
|
495
|
+
if (k === 'a') { decide(ask, 'allow', e.shiftKey ? 'always' : 'once'); e.preventDefault(); }
|
|
496
|
+
if (k === 'd') { decide(ask, 'deny', e.shiftKey ? 'always' : 'once'); e.preventDefault(); }
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// One ticker for every countdown on the page.
|
|
500
|
+
function tickWaits() {
|
|
501
|
+
const limit = S.askTimeoutMs;
|
|
502
|
+
document.querySelectorAll('[data-wait]').forEach((el) => {
|
|
503
|
+
const held = Date.now() - +el.dataset.wait;
|
|
504
|
+
el.textContent = `held ${Math.round(held / 1000)}s`;
|
|
505
|
+
el.dataset.urgent = held > limit * 0.75 ? '2' : held > limit * 0.4 ? '1' : '0';
|
|
506
|
+
});
|
|
507
|
+
document.querySelectorAll('[data-wait-bar]').forEach((el) => {
|
|
508
|
+
const held = Date.now() - +el.dataset.waitBar;
|
|
509
|
+
const left = Math.max(0, limit - held);
|
|
510
|
+
const frac = Math.max(0, Math.min(1, left / limit));
|
|
511
|
+
el.querySelector('i').style.width = (frac * 100) + '%';
|
|
512
|
+
el.dataset.urgent = frac < 0.25 ? '2' : frac < 0.6 ? '1' : '0';
|
|
513
|
+
el.querySelector('.cap').textContent = left > 0
|
|
514
|
+
? `Denied automatically in ${Math.ceil(left / 1000)}s if nobody answers.`
|
|
515
|
+
: 'Timed out. Denied, because this fails closed.';
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
setInterval(tickWaits, 1000);
|
|
519
|
+
renderAsks(); renderRules();
|
|
520
|
+
</script>
|
|
521
|
+
</body>
|
|
522
|
+
</html>
|