visual-comments 0.1.0 → 0.2.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/README.md +19 -2
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +477 -0
- package/dist/core/presence.d.ts +13 -0
- package/dist/core/presence.js +25 -0
- package/dist/core/threads.svelte.d.ts +36 -2
- package/dist/core/threads.svelte.js +120 -3
- package/dist/index.d.ts +1 -1
- package/dist/server/env.d.ts +78 -0
- package/dist/server/env.js +94 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +10 -3
- package/dist/server/variables.d.ts +28 -0
- package/dist/server/variables.js +73 -0
- package/dist/shared/protocol.d.ts +4 -0
- package/dist/shared/protocol.js +4 -0
- package/dist/shared/types.d.ts +64 -0
- package/dist/sveltekit/CommentTools.svelte +98 -0
- package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
- package/dist/sveltekit/Comments.svelte +104 -0
- package/dist/sveltekit/Comments.svelte.d.ts +25 -0
- package/dist/sveltekit/Toolbar.svelte +64 -0
- package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
- package/dist/sveltekit/ToolbarButton.svelte +66 -0
- package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
- package/dist/sveltekit/context.d.ts +16 -0
- package/dist/sveltekit/context.js +9 -0
- package/dist/sveltekit/index.d.ts +6 -0
- package/dist/sveltekit/index.js +6 -0
- package/dist/ui/Account.svelte +45 -7
- package/dist/ui/App.svelte +12 -3
- package/dist/ui/Avatar.svelte +28 -6
- package/dist/ui/Avatar.svelte.d.ts +4 -5
- package/dist/ui/Connect.svelte +128 -0
- package/dist/ui/Connect.svelte.d.ts +6 -0
- package/dist/ui/Menu.svelte +5 -1
- package/dist/ui/Menu.svelte.d.ts +2 -0
- package/dist/ui/Overlay.svelte +15 -1
- package/dist/ui/Panel.svelte +141 -127
- package/dist/ui/Pin.svelte +22 -2
- package/dist/ui/Pin.svelte.d.ts +2 -0
- package/dist/ui/Presence.svelte +43 -0
- package/dist/ui/Presence.svelte.d.ts +8 -0
- package/dist/ui/ThreadCard.svelte +26 -2
- package/dist/ui/generated/styles.css +1 -1
- package/dist/ui/generated/styles.js +2 -2
- package/dist/ui/styles.css +21 -0
- package/dist/ui/text.d.ts +3 -1
- package/dist/ui/text.js +4 -0
- package/dist/visual-comments.svelte.d.ts +53 -4
- package/dist/visual-comments.svelte.js +154 -29
- package/dist/vite/index.d.ts +20 -0
- package/dist/vite/index.js +78 -0
- package/dist-widget/visual-comments.js +17 -10
- package/package.json +88 -66
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ they mean, and start a thread pinned to it. One self-hosted comment server
|
|
|
6
6
|
holds the threads of many sites; each site signs its own people in and passes
|
|
7
7
|
their comment requests on through its own server.
|
|
8
8
|
|
|
9
|
-
This package is the browser library (Svelte 5), the server-side proxy,
|
|
10
|
-
script-tag build. The comment server and full documentation live in the
|
|
9
|
+
This package is the browser library (Svelte 5), the server-side proxy, the
|
|
10
|
+
SvelteKit adapter, the command for coding agents, and the script-tag build. The comment server and full documentation live in the
|
|
11
11
|
[repository](https://github.com/Convex-Works/visual-comments).
|
|
12
12
|
|
|
13
13
|
```sh
|
|
@@ -56,6 +56,23 @@ comment server at `/v1/visual-comments.js` and also shipped here as
|
|
|
56
56
|
`visual-comments/widget.js`; see the
|
|
57
57
|
[markup reference](https://github.com/Convex-Works/visual-comments/blob/main/docs/MARKUP.md).
|
|
58
58
|
|
|
59
|
+
## SvelteKit
|
|
60
|
+
|
|
61
|
+
`visual-comments/sveltekit` sets the comments up from environment variables
|
|
62
|
+
(`VISUAL_COMMENTS_ENABLED`, `_SERVER`, `_SPACE`, `_SECRET`): a hook, a layout
|
|
63
|
+
`load`, and `<Comments>` with a `<Toolbar>` at the bottom of the window. They
|
|
64
|
+
can sign people in on their own, apart from the app's accounts, and the
|
|
65
|
+
`visualComments()` Vite plugin from `visual-comments/vite` leaves them out of
|
|
66
|
+
production builds. See the
|
|
67
|
+
[SvelteKit guide](https://github.com/Convex-Works/visual-comments/blob/main/docs/SVELTEKIT.md).
|
|
68
|
+
|
|
69
|
+
## Coding agents
|
|
70
|
+
|
|
71
|
+
The package's `visual-comments` command lets a coding agent join a space:
|
|
72
|
+
`npx visual-comments connect <site>`, then `next` to wait for new comments,
|
|
73
|
+
`reply` and `presence`. See
|
|
74
|
+
[coding agents](https://github.com/Convex-Works/visual-comments/blob/main/docs/CODING-AGENTS.md).
|
|
75
|
+
|
|
59
76
|
## License
|
|
60
77
|
|
|
61
78
|
MIT
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
// The `visual-comments` command, for coding agents: join a site's comment space
|
|
4
|
+
// once a person allows it, wait for new comments, reply, and say what you are
|
|
5
|
+
// doing. It talks only to the site's comments route (`/_comments`), with the
|
|
6
|
+
// agent's token. Node built-ins only, and compiled, because Node does not strip
|
|
7
|
+
// types in node_modules.
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { homedir, tmpdir } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
13
|
+
import { parseArgs } from 'node:util';
|
|
14
|
+
const DEFAULT_PATH = '/_comments';
|
|
15
|
+
/** The server holds `/agent/next` and the token request up to 50 s; give it room. */
|
|
16
|
+
const LONG_POLL_MS = 80_000;
|
|
17
|
+
const REQUEST_MS = 30_000;
|
|
18
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
19
|
+
const IMAGE_EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' };
|
|
20
|
+
const HELP = {
|
|
21
|
+
main: `Usage: visual-comments <command> [options]
|
|
22
|
+
|
|
23
|
+
Lets a coding agent take part in a site's comments. A person allows it once in
|
|
24
|
+
the browser; then it waits for new comments, replies, and shows what it is doing.
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
connect <site> Ask to join the site's comments, and wait for a person to allow it.
|
|
28
|
+
next Wait for new comments; print them as JSON.
|
|
29
|
+
reply <thread> Reply in a thread, with --body or stdin.
|
|
30
|
+
presence <state> Say what you are doing: viewing, working or gone.
|
|
31
|
+
agents List the space's agents.
|
|
32
|
+
disconnect Leave the space and forget the token.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--site <site> Which connected site, when there are several. Or set VISUAL_COMMENTS_SITE.
|
|
36
|
+
-h, --help Help for a command: visual-comments <command> --help
|
|
37
|
+
|
|
38
|
+
Tokens are kept in \${XDG_CONFIG_HOME:-~/.config}/visual-comments/agents.json.
|
|
39
|
+
Exit codes: 0 done, 1 error, 2 not connected or the token was rejected.`,
|
|
40
|
+
connect: `Usage: visual-comments connect <site> [--name <name>] [--path <path>]
|
|
41
|
+
|
|
42
|
+
Asks to join the site's comments, prints the address where a person allows it
|
|
43
|
+
(and opens it in the browser), waits, then saves the token.
|
|
44
|
+
|
|
45
|
+
--name <name> The name people see on your comments. Default "Agent".
|
|
46
|
+
--path <path> Where the site answers comment requests. Default /_comments.
|
|
47
|
+
|
|
48
|
+
Example: visual-comments connect http://localhost:5173 --name Claude`,
|
|
49
|
+
next: `Usage: visual-comments next [--site <site>]
|
|
50
|
+
|
|
51
|
+
Waits, as long as it takes, until a thread has something new for you, then
|
|
52
|
+
prints { site, threads, participants } as JSON and exits. The threads are then
|
|
53
|
+
read for you. Images are downloaded; each gets a "path". Network errors are
|
|
54
|
+
retried. Run it again at once to keep listening.`,
|
|
55
|
+
reply: `Usage: visual-comments reply <thread> [--body <text>] [--site <site>]
|
|
56
|
+
|
|
57
|
+
Replies in thread #<thread>. Without --body, the text is read from stdin.
|
|
58
|
+
Prints { thread: { id, url, comment } }.
|
|
59
|
+
|
|
60
|
+
Example: echo "Done: the table is tighter." | visual-comments reply 12`,
|
|
61
|
+
presence: `Usage: visual-comments presence <viewing|working|gone> [options]
|
|
62
|
+
|
|
63
|
+
Says what you are doing, for everyone in the space to see. Each update states
|
|
64
|
+
the whole presence: options left out are cleared.
|
|
65
|
+
|
|
66
|
+
--key <key> One presence per key, to work on several things at once. Default "".
|
|
67
|
+
--thread <n> The thread it is about.
|
|
68
|
+
--surface <id> The surface (e.g. a frame) it is about.
|
|
69
|
+
--page <page> The page it is about.
|
|
70
|
+
--note <text> What you are doing, e.g. "Tightening the table".
|
|
71
|
+
--site <site> Which connected site.
|
|
72
|
+
|
|
73
|
+
Example: visual-comments presence working --key 12 --thread 12 --note "Tightening the table"
|
|
74
|
+
visual-comments presence gone --key 12`,
|
|
75
|
+
agents: `Usage: visual-comments agents [--site <site>]
|
|
76
|
+
|
|
77
|
+
Prints the space's agents as JSON: { site, me, agents }.`,
|
|
78
|
+
disconnect: `Usage: visual-comments disconnect [--site <site>]
|
|
79
|
+
|
|
80
|
+
Removes you from the space and forgets the token.`
|
|
81
|
+
};
|
|
82
|
+
/** Ends the command with a message and an exit code. */
|
|
83
|
+
class CliError extends Error {
|
|
84
|
+
code;
|
|
85
|
+
constructor(code, message) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.code = code;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** A failed request. Status 0 when the site could not be reached. */
|
|
91
|
+
class HttpError extends Error {
|
|
92
|
+
status;
|
|
93
|
+
constructor(status, message) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.status = status;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const log = (message) => process.stderr.write(`${message}\n`);
|
|
99
|
+
/** Asked for, so on stdout; usage errors go to stderr. */
|
|
100
|
+
const help = (text) => process.stdout.write(`${text}\n`);
|
|
101
|
+
const print = (value) => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
102
|
+
function parse(command, args, options) {
|
|
103
|
+
try {
|
|
104
|
+
return parseArgs({ args, options: { ...options, help: { type: 'boolean', short: 'h' } }, allowPositionals: true, strict: true });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new CliError(1, `${error.message}\n\n${HELP[command]}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Sites and saved tokens
|
|
111
|
+
/** A site as typed: `localhost:5173` works too. */
|
|
112
|
+
function siteUrl(site) {
|
|
113
|
+
const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(site)
|
|
114
|
+
? site
|
|
115
|
+
: `${/^(localhost|127\.|\[::1\])/.test(site) ? 'http' : 'https'}://${site}`;
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(withScheme);
|
|
118
|
+
if (url.protocol === 'http:' || url.protocol === 'https:')
|
|
119
|
+
return url;
|
|
120
|
+
}
|
|
121
|
+
catch { /* below */ }
|
|
122
|
+
throw new CliError(1, `"${site}" is not a web address.`);
|
|
123
|
+
}
|
|
124
|
+
/** The site, with any path, and where it answers comment requests: `<origin><path>`, which keys the saved token. */
|
|
125
|
+
function locate(site, path = DEFAULT_PATH) {
|
|
126
|
+
const url = siteUrl(site);
|
|
127
|
+
const route = `/${path.replace(/^\/+|\/+$/g, '')}`;
|
|
128
|
+
return { site: `${url.origin}${url.pathname.replace(/\/+$/, '')}`, endpoint: `${url.origin}${route === '/' ? '' : route}` };
|
|
129
|
+
}
|
|
130
|
+
function credentialsFile() {
|
|
131
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'visual-comments', 'agents.json');
|
|
132
|
+
}
|
|
133
|
+
function loadAll() {
|
|
134
|
+
const file = credentialsFile();
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
140
|
+
return {};
|
|
141
|
+
throw new CliError(1, `${file} cannot be read: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function saveAll(all) {
|
|
145
|
+
const file = credentialsFile();
|
|
146
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
147
|
+
// Written whole, then moved over the old file, so a crash never leaves half of it. Only you can read it.
|
|
148
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
149
|
+
writeFileSync(temporary, `${JSON.stringify(all, null, '\t')}\n`, { mode: 0o600 });
|
|
150
|
+
renameSync(temporary, file);
|
|
151
|
+
}
|
|
152
|
+
/** The site the command is for, when it is connected. Set for the hint on a rejected token. */
|
|
153
|
+
let current;
|
|
154
|
+
function connected(site) {
|
|
155
|
+
const all = loadAll();
|
|
156
|
+
const endpoints = Object.keys(all);
|
|
157
|
+
const wanted = site ?? process.env.VISUAL_COMMENTS_SITE;
|
|
158
|
+
let matches = endpoints;
|
|
159
|
+
if (wanted) {
|
|
160
|
+
const url = siteUrl(wanted);
|
|
161
|
+
const exact = `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
|
162
|
+
matches = endpoints.filter((endpoint) => endpoint === exact);
|
|
163
|
+
if (!matches.length)
|
|
164
|
+
matches = endpoints.filter((endpoint) => new URL(endpoint).origin === url.origin);
|
|
165
|
+
if (!matches.length)
|
|
166
|
+
throw new CliError(2, `Not connected to ${wanted}. Run: visual-comments connect ${wanted}`);
|
|
167
|
+
}
|
|
168
|
+
else if (!matches.length) {
|
|
169
|
+
throw new CliError(2, 'Not connected to any site. Run: visual-comments connect <site>');
|
|
170
|
+
}
|
|
171
|
+
if (matches.length > 1) {
|
|
172
|
+
throw new CliError(1, `Connected to several sites; choose one with --site or VISUAL_COMMENTS_SITE:\n ${matches.join('\n ')}`);
|
|
173
|
+
}
|
|
174
|
+
const endpoint = matches[0];
|
|
175
|
+
current = { endpoint, site: new URL(endpoint).origin, saved: all[endpoint] };
|
|
176
|
+
return current;
|
|
177
|
+
}
|
|
178
|
+
// Requests
|
|
179
|
+
async function call(endpoint, route, init = {}) {
|
|
180
|
+
const headers = { accept: 'application/json' };
|
|
181
|
+
if (init.token)
|
|
182
|
+
headers.authorization = `Bearer ${init.token}`;
|
|
183
|
+
if (init.body !== undefined)
|
|
184
|
+
headers['content-type'] = 'application/json';
|
|
185
|
+
let response;
|
|
186
|
+
let text;
|
|
187
|
+
try {
|
|
188
|
+
response = await fetch(`${endpoint}${route}`, {
|
|
189
|
+
method: init.method ?? 'GET',
|
|
190
|
+
headers,
|
|
191
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
192
|
+
signal: AbortSignal.timeout(init.timeout ?? REQUEST_MS)
|
|
193
|
+
});
|
|
194
|
+
text = await response.text();
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
throw new HttpError(0, `${endpoint} cannot be reached (${reason(error)}).`);
|
|
198
|
+
}
|
|
199
|
+
let data;
|
|
200
|
+
try {
|
|
201
|
+
data = text ? JSON.parse(text) : undefined;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// HTML from the site itself, e.g. a sign-in page: the comments route is elsewhere.
|
|
205
|
+
const message = `${endpoint}${route} did not answer like a comments route (status ${response.status}). Is --path right?`;
|
|
206
|
+
throw new HttpError(response.ok ? 404 : response.status, message);
|
|
207
|
+
}
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
const error = data && typeof data === 'object' && 'error' in data ? data.error : undefined;
|
|
210
|
+
throw new HttpError(response.status, typeof error === 'string' ? error : `The site answered ${response.status}.`);
|
|
211
|
+
}
|
|
212
|
+
return { status: response.status, data: data };
|
|
213
|
+
}
|
|
214
|
+
function reason(error) {
|
|
215
|
+
if (error instanceof Error && error.name === 'TimeoutError')
|
|
216
|
+
return 'no answer in time';
|
|
217
|
+
if (!(error instanceof Error))
|
|
218
|
+
return String(error);
|
|
219
|
+
// fetch fails with "fetch failed"; the cause says why, e.g. ECONNREFUSED.
|
|
220
|
+
const cause = error.cause;
|
|
221
|
+
if (cause instanceof Error)
|
|
222
|
+
return 'code' in cause && typeof cause.code === 'string' ? cause.code : cause.message;
|
|
223
|
+
return error.message;
|
|
224
|
+
}
|
|
225
|
+
/** Worth trying again: the site could not be reached, or had a passing problem. */
|
|
226
|
+
function transient(error) {
|
|
227
|
+
return error instanceof HttpError && (error.status === 0 || error.status === 408 || error.status === 429 || error.status >= 500);
|
|
228
|
+
}
|
|
229
|
+
function backoff(previous) {
|
|
230
|
+
return previous ? Math.min(previous * 2, MAX_BACKOFF_MS) : 1000;
|
|
231
|
+
}
|
|
232
|
+
// Commands
|
|
233
|
+
async function connect(args) {
|
|
234
|
+
const { values, positionals } = parse('connect', args, { name: { type: 'string' }, path: { type: 'string' } });
|
|
235
|
+
if (values.help)
|
|
236
|
+
return help(HELP.connect);
|
|
237
|
+
if (positionals.length !== 1)
|
|
238
|
+
throw new CliError(1, HELP.connect);
|
|
239
|
+
const { site, endpoint } = locate(positionals[0], values.path);
|
|
240
|
+
const name = (values.name ?? 'Agent').trim();
|
|
241
|
+
if (!name || name.length > 60)
|
|
242
|
+
throw new CliError(1, 'The name must be 1 to 60 characters.');
|
|
243
|
+
// Joining again would add a second agent to the space; a saved token that still works is enough.
|
|
244
|
+
const existing = loadAll()[endpoint];
|
|
245
|
+
if (existing) {
|
|
246
|
+
try {
|
|
247
|
+
const { data } = await call(endpoint, '/agents', { token: existing.token });
|
|
248
|
+
const agent = data.agents.find((agent) => agent.id === existing.agent.id);
|
|
249
|
+
if (agent) {
|
|
250
|
+
log(`Already connected to ${site} as ${agent.name}. To join again, run visual-comments disconnect first.`);
|
|
251
|
+
return print({ site, agent });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (!(error instanceof HttpError && error.status === 401))
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const { data: request } = await call(endpoint, '/agents/requests', { method: 'POST', body: { name } });
|
|
260
|
+
const approval = `${site}/#vc-connect=${encodeURIComponent(request.id)}`;
|
|
261
|
+
log(`To let ${name} join, open\n\n ${approval}\n\ncheck that it shows the code ${request.code}, and click Allow. Waiting…`);
|
|
262
|
+
openBrowser(approval);
|
|
263
|
+
// The server answers 404 once the request expires; this only ends retries against a site that stays down.
|
|
264
|
+
const deadline = Date.now() + Math.max(request.expiresAt - Date.now(), 0) + 60_000;
|
|
265
|
+
let delay = 0;
|
|
266
|
+
while (Date.now() < deadline) {
|
|
267
|
+
try {
|
|
268
|
+
const { data } = await call(endpoint, `/agents/requests/${encodeURIComponent(request.id)}/token`, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
body: { secret: request.secret },
|
|
271
|
+
timeout: LONG_POLL_MS
|
|
272
|
+
});
|
|
273
|
+
delay = 0;
|
|
274
|
+
if ('token' in data) {
|
|
275
|
+
saveAll({ ...loadAll(), [endpoint]: { token: data.token, agent: data.agent } });
|
|
276
|
+
log(`Connected to ${site} as ${data.agent.name}, added by ${data.agent.addedBy.name}.`);
|
|
277
|
+
return print({ site, agent: data.agent });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error instanceof HttpError && error.status === 404)
|
|
282
|
+
throw new CliError(1, 'The request was denied, or it expired. Run connect again.');
|
|
283
|
+
if (!transient(error))
|
|
284
|
+
throw error;
|
|
285
|
+
delay = backoff(delay);
|
|
286
|
+
log(`${error.message} Trying again in ${delay / 1000} s.`);
|
|
287
|
+
await sleep(delay);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
throw new CliError(1, 'The request expired. Run connect again.');
|
|
291
|
+
}
|
|
292
|
+
/** Best effort: the address is printed anyway. */
|
|
293
|
+
function openBrowser(url) {
|
|
294
|
+
const [command, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
295
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
|
|
296
|
+
: ['xdg-open', [url]];
|
|
297
|
+
try {
|
|
298
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: true });
|
|
299
|
+
child.on('error', () => { });
|
|
300
|
+
child.unref();
|
|
301
|
+
}
|
|
302
|
+
catch { /* no browser here */ }
|
|
303
|
+
}
|
|
304
|
+
async function next(args) {
|
|
305
|
+
const { values } = parse('next', args, { site: { type: 'string' } });
|
|
306
|
+
if (values.help)
|
|
307
|
+
return help(HELP.next);
|
|
308
|
+
const { endpoint, site, saved } = connected(values.site);
|
|
309
|
+
// Each request is short; the loop is what waits, so no proxy times out a request held for hours.
|
|
310
|
+
let delay = 0;
|
|
311
|
+
for (;;) {
|
|
312
|
+
let inbox;
|
|
313
|
+
try {
|
|
314
|
+
({ data: inbox } = await call(endpoint, '/agent/next?wait=50', { token: saved.token, timeout: LONG_POLL_MS }));
|
|
315
|
+
delay = 0;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (!transient(error))
|
|
319
|
+
throw error;
|
|
320
|
+
delay = backoff(delay);
|
|
321
|
+
log(`${error.message} Trying again in ${delay / 1000} s.`);
|
|
322
|
+
await sleep(delay);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!inbox.threads.length)
|
|
326
|
+
continue;
|
|
327
|
+
// The threads are marked read now, so from here nothing may fail: an image that will not download keeps no path.
|
|
328
|
+
await downloadImages(endpoint, saved.token, inbox.threads);
|
|
329
|
+
return print({ site, threads: inbox.threads, participants: inbox.participants });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function downloadImages(endpoint, token, threads) {
|
|
333
|
+
const host = new URL(endpoint).host;
|
|
334
|
+
const folder = join(tmpdir(), 'visual-comments', process.platform === 'win32' ? host.replace(/:/g, '_') : host);
|
|
335
|
+
mkdirSync(folder, { recursive: true });
|
|
336
|
+
for (const comment of threads.flatMap((thread) => thread.comments)) {
|
|
337
|
+
for (const image of comment.images) {
|
|
338
|
+
const name = `${image.id}-${image.key}`;
|
|
339
|
+
// An image's address never changes, so one downloaded before is the same image.
|
|
340
|
+
const known = Object.values(IMAGE_EXTENSIONS).map((extension) => join(folder, `${name}.${extension}`)).find((path) => existsSync(path));
|
|
341
|
+
if (known) {
|
|
342
|
+
image.path = known;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
image.path = await downloadImage(`${endpoint}/images/${image.id}/${encodeURIComponent(image.key)}`, token, join(folder, name));
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
log(`Image ${image.id} could not be downloaded: ${error.message}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function downloadImage(url, token, base) {
|
|
355
|
+
for (let attempt = 1;; attempt++) {
|
|
356
|
+
try {
|
|
357
|
+
const response = await fetch(url, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(REQUEST_MS) });
|
|
358
|
+
if (!response.ok)
|
|
359
|
+
throw new HttpError(response.status, `the site answered ${response.status}`);
|
|
360
|
+
const type = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() ?? '';
|
|
361
|
+
const path = `${base}.${IMAGE_EXTENSIONS[type] ?? 'bin'}`;
|
|
362
|
+
writeFileSync(path, new Uint8Array(await response.arrayBuffer()));
|
|
363
|
+
return path;
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
const failure = error instanceof HttpError ? error : new HttpError(0, reason(error));
|
|
367
|
+
if (attempt >= 3 || !transient(failure))
|
|
368
|
+
throw failure;
|
|
369
|
+
await sleep(1000 * attempt);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function reply(args) {
|
|
374
|
+
const { values, positionals } = parse('reply', args, { body: { type: 'string' }, site: { type: 'string' } });
|
|
375
|
+
if (values.help)
|
|
376
|
+
return help(HELP.reply);
|
|
377
|
+
if (positionals.length !== 1 || !/^\d+$/.test(positionals[0].replace(/^#/, '')))
|
|
378
|
+
throw new CliError(1, HELP.reply);
|
|
379
|
+
const number = Number(positionals[0].replace(/^#/, ''));
|
|
380
|
+
const { endpoint, saved } = connected(values.site);
|
|
381
|
+
const body = (values.body ?? (process.stdin.isTTY ? '' : await readStdin())).trim();
|
|
382
|
+
if (!body)
|
|
383
|
+
throw new CliError(1, 'The reply is empty. Pass --body, or the text on stdin.');
|
|
384
|
+
const { data } = await call(endpoint, `/threads/${number}/comments`, { method: 'POST', token: saved.token, body: { body } });
|
|
385
|
+
print({ thread: { id: data.thread.id, url: data.thread.url, comment: data.thread.comments.at(-1)?.id } });
|
|
386
|
+
}
|
|
387
|
+
async function readStdin() {
|
|
388
|
+
const chunks = [];
|
|
389
|
+
for await (const chunk of process.stdin)
|
|
390
|
+
chunks.push(chunk);
|
|
391
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
392
|
+
}
|
|
393
|
+
async function presence(args) {
|
|
394
|
+
const { values, positionals } = parse('presence', args, {
|
|
395
|
+
key: { type: 'string' },
|
|
396
|
+
thread: { type: 'string' },
|
|
397
|
+
surface: { type: 'string' },
|
|
398
|
+
page: { type: 'string' },
|
|
399
|
+
note: { type: 'string' },
|
|
400
|
+
site: { type: 'string' }
|
|
401
|
+
});
|
|
402
|
+
if (values.help)
|
|
403
|
+
return help(HELP.presence);
|
|
404
|
+
const state = positionals[0];
|
|
405
|
+
if (positionals.length !== 1 || (state !== 'viewing' && state !== 'working' && state !== 'gone'))
|
|
406
|
+
throw new CliError(1, HELP.presence);
|
|
407
|
+
const update = { state };
|
|
408
|
+
if (values.key !== undefined)
|
|
409
|
+
update.key = values.key;
|
|
410
|
+
if (values.thread !== undefined) {
|
|
411
|
+
const thread = Number(values.thread.replace(/^#/, ''));
|
|
412
|
+
if (!Number.isInteger(thread) || thread < 1)
|
|
413
|
+
throw new CliError(1, '--thread must be a thread number.');
|
|
414
|
+
update.thread = thread;
|
|
415
|
+
}
|
|
416
|
+
if (values.surface !== undefined)
|
|
417
|
+
update.surface = values.surface;
|
|
418
|
+
if (values.page !== undefined)
|
|
419
|
+
update.page = values.page;
|
|
420
|
+
if (values.note !== undefined)
|
|
421
|
+
update.note = values.note;
|
|
422
|
+
const { endpoint, saved } = connected(values.site);
|
|
423
|
+
await call(endpoint, '/presence', { method: 'POST', token: saved.token, body: update });
|
|
424
|
+
}
|
|
425
|
+
async function agents(args) {
|
|
426
|
+
const { values } = parse('agents', args, { site: { type: 'string' } });
|
|
427
|
+
if (values.help)
|
|
428
|
+
return help(HELP.agents);
|
|
429
|
+
const { endpoint, site, saved } = connected(values.site);
|
|
430
|
+
const { data } = await call(endpoint, '/agents', { token: saved.token });
|
|
431
|
+
print({ site, me: saved.agent.id, agents: data.agents });
|
|
432
|
+
}
|
|
433
|
+
async function disconnect(args) {
|
|
434
|
+
const { values } = parse('disconnect', args, { site: { type: 'string' } });
|
|
435
|
+
if (values.help)
|
|
436
|
+
return help(HELP.disconnect);
|
|
437
|
+
const { endpoint, site, saved } = connected(values.site);
|
|
438
|
+
try {
|
|
439
|
+
await call(endpoint, `/agents/${encodeURIComponent(saved.agent.id)}`, { method: 'DELETE', token: saved.token });
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
// Already removed by a person, or expired: forgetting the token is all that is left.
|
|
443
|
+
if (!(error instanceof HttpError && error.status === 401))
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
const all = loadAll();
|
|
447
|
+
delete all[endpoint];
|
|
448
|
+
saveAll(all);
|
|
449
|
+
log(`Disconnected from ${site}.`);
|
|
450
|
+
print({ deleted: saved.agent.id });
|
|
451
|
+
}
|
|
452
|
+
const COMMANDS = { connect, next, reply, presence, agents, disconnect };
|
|
453
|
+
async function main(argv) {
|
|
454
|
+
const [command, ...args] = argv;
|
|
455
|
+
if (!command || command === '-h' || command === '--help' || command === 'help') {
|
|
456
|
+
help(HELP.main);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const run = COMMANDS[command];
|
|
460
|
+
if (!run)
|
|
461
|
+
throw new CliError(1, `Unknown command "${command}".\n\n${HELP.main}`);
|
|
462
|
+
await run(args);
|
|
463
|
+
}
|
|
464
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
465
|
+
if (error instanceof CliError) {
|
|
466
|
+
log(error.message);
|
|
467
|
+
process.exitCode = error.code;
|
|
468
|
+
}
|
|
469
|
+
else if (error instanceof HttpError && error.status === 401 && current) {
|
|
470
|
+
log(`${error.message} Run: visual-comments connect ${current.site}`);
|
|
471
|
+
process.exitCode = 2;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
log(error instanceof Error ? error.message : String(error));
|
|
475
|
+
process.exitCode = 1;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Author, Participant, PresenceState } from '../shared/types.js';
|
|
2
|
+
/** Someone present, however many tabs or tasks they have. */
|
|
3
|
+
export interface Present {
|
|
4
|
+
author: Author;
|
|
5
|
+
/** Their busiest participant's state. */
|
|
6
|
+
state: PresenceState;
|
|
7
|
+
/** Busiest first. */
|
|
8
|
+
participants: Participant[];
|
|
9
|
+
}
|
|
10
|
+
/** Participants grouped by who they are, in the order they arrived. */
|
|
11
|
+
export declare function present(participants: Participant[]): Present[];
|
|
12
|
+
/** What a participant is doing, as a sentence, or undefined while they only look. */
|
|
13
|
+
export declare function activity(participant: Participant): string | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const RANK = { viewing: 0, typing: 1, working: 2 };
|
|
2
|
+
/** Participants grouped by who they are, in the order they arrived. */
|
|
3
|
+
export function present(participants) {
|
|
4
|
+
const groups = new Map();
|
|
5
|
+
for (const participant of participants) {
|
|
6
|
+
const group = groups.get(participant.author.sub);
|
|
7
|
+
if (group)
|
|
8
|
+
group.push(participant);
|
|
9
|
+
else
|
|
10
|
+
groups.set(participant.author.sub, [participant]);
|
|
11
|
+
}
|
|
12
|
+
return [...groups.values()].map((group) => {
|
|
13
|
+
group.sort((a, b) => RANK[b.state] - RANK[a.state]);
|
|
14
|
+
return { author: group[0].author, state: group[0].state, participants: group };
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** What a participant is doing, as a sentence, or undefined while they only look. */
|
|
18
|
+
export function activity(participant) {
|
|
19
|
+
const { name } = participant.author;
|
|
20
|
+
if (participant.state === 'typing')
|
|
21
|
+
return `${name} is typing…`;
|
|
22
|
+
if (participant.state === 'working')
|
|
23
|
+
return participant.note ? `${name} is working: ${participant.note}` : `${name} is working…`;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Api } from './api.js';
|
|
2
|
-
import type { CommentImage, CommentThread, NewThreadInput, Viewer } from '../shared/types.js';
|
|
2
|
+
import type { Agent, AgentRequest, CommentImage, CommentThread, NewThreadInput, Participant, PresenceUpdate, Viewer } from '../shared/types.js';
|
|
3
|
+
/** What this tab says about itself; its connection is added once the event stream names it. */
|
|
4
|
+
export type OwnPresence = Omit<PresenceUpdate, 'connection' | 'key'>;
|
|
3
5
|
/**
|
|
4
6
|
* The space's threads, kept current with the server's event stream. Actions
|
|
5
7
|
* show their result at once and resync with the server when they fail.
|
|
@@ -9,23 +11,48 @@ export declare class Threads {
|
|
|
9
11
|
threads: CommentThread[];
|
|
10
12
|
spaceName: string;
|
|
11
13
|
loading: boolean;
|
|
14
|
+
/** Whether `viewer` is known: from the server's answer, or given when the page loaded. */
|
|
15
|
+
viewerKnown: boolean;
|
|
12
16
|
/** A failed action, shown until dismissed. */
|
|
13
17
|
error: string | null;
|
|
14
18
|
/** Ticks every 30 seconds so relative times stay current. */
|
|
15
19
|
now: number;
|
|
20
|
+
/** This tab's event stream, as the server named it, while it is open. */
|
|
21
|
+
connection: string | undefined;
|
|
22
|
+
/** Everyone present in the space, this tab included, while the event stream is open. */
|
|
23
|
+
participants: Participant[];
|
|
24
|
+
/** The space's agents once listed; undefined until then, or when the space has them turned off. */
|
|
25
|
+
agents: Agent[] | undefined;
|
|
16
26
|
private api;
|
|
17
27
|
private refreshing?;
|
|
18
28
|
private queued;
|
|
19
29
|
private listening?;
|
|
20
30
|
private nextPending;
|
|
21
|
-
|
|
31
|
+
private presence?;
|
|
32
|
+
/** The last presence sent, with its connection, so an unchanged one is not sent again. */
|
|
33
|
+
private posted;
|
|
34
|
+
/** The participants as last received, so an unchanged list does not wake every listener. */
|
|
35
|
+
private received;
|
|
36
|
+
/** `viewer`: who is signed in, when the page already knows it; undefined to wait for the server. */
|
|
37
|
+
constructor(api: Api, viewer?: Viewer | null);
|
|
22
38
|
get unreadCount(): number;
|
|
23
39
|
start(): () => void;
|
|
24
40
|
/** Coalesces overlapping refreshes into at most one more request. */
|
|
25
41
|
refresh(): Promise<void>;
|
|
26
42
|
private stopListening;
|
|
43
|
+
/** Without the stream, who is present is unknown, and the server has already let this tab go. */
|
|
44
|
+
private disconnected;
|
|
45
|
+
private setParticipants;
|
|
27
46
|
/** Follows the event stream; after a dropped connection, catches up on anything missed. */
|
|
28
47
|
private listen;
|
|
48
|
+
/** One message of the event stream. Servers without presence send only `change`. */
|
|
49
|
+
private receive;
|
|
50
|
+
/**
|
|
51
|
+
* Says what this tab is doing, unless it said so already. `again` sends it
|
|
52
|
+
* even so, as the heartbeat that keeps `typing` from lapsing.
|
|
53
|
+
*/
|
|
54
|
+
setPresence(presence: OwnPresence, again?: boolean): void;
|
|
55
|
+
private postPresence;
|
|
29
56
|
private request;
|
|
30
57
|
private apply;
|
|
31
58
|
/** Runs an action, showing its error and resyncing with the server if it fails. */
|
|
@@ -43,4 +70,11 @@ export declare class Threads {
|
|
|
43
70
|
resolve(threadId: number, resolved: boolean): Promise<void>;
|
|
44
71
|
markRead(threadId: number, read: boolean): Promise<void>;
|
|
45
72
|
markAllRead(): Promise<void>;
|
|
73
|
+
loadAgents(): Promise<void>;
|
|
74
|
+
/** Takes an agent out of the space at once: its token stops working. */
|
|
75
|
+
removeAgent(id: string): Promise<void>;
|
|
76
|
+
/** An agent asking to join, for the person approving it. Throws when it has expired or was answered. */
|
|
77
|
+
agentRequest(id: string): Promise<AgentRequest>;
|
|
78
|
+
approveAgent(id: string): Promise<Agent>;
|
|
79
|
+
denyAgent(id: string): Promise<void>;
|
|
46
80
|
}
|