spectater 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/src/server.js CHANGED
@@ -4,7 +4,6 @@ import { readFile } from 'node:fs/promises';
4
4
  import http from 'node:http';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { promisify } from 'node:util';
7
- import { startControl } from './control.js';
8
7
  import { feedback, feedbackMarkdown } from './storage.js';
9
8
  import { watchChange } from './watch.js';
10
9
 
@@ -15,16 +14,50 @@ const assets = new Map([
15
14
  ['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
16
15
  ]);
17
16
  const shell = await readFile(new URL('../web/index.html', import.meta.url), 'utf8');
17
+ const headers = {
18
+ 'Content-Security-Policy':
19
+ "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
20
+ 'X-Content-Type-Options': 'nosniff',
21
+ 'X-Frame-Options': 'DENY',
22
+ 'Referrer-Policy': 'no-referrer',
23
+ 'Cache-Control': 'no-store',
24
+ };
25
+ export const MAX_WAIT_MS = 3_600_000;
18
26
 
27
+ async function readJson(request) {
28
+ let bytes = 0;
29
+ const chunks = [];
30
+ for await (const chunk of request) {
31
+ bytes += chunk.length;
32
+ if (bytes > 65536) {
33
+ const error = new Error('Request exceeds 64 KiB.');
34
+ error.statusCode = 413;
35
+ throw error;
36
+ }
37
+ chunks.push(chunk);
38
+ }
39
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
40
+ if (!input || typeof input !== 'object' || Array.isArray(input))
41
+ throw new Error('Expected a JSON object.');
42
+ return input;
43
+ }
44
+
45
+ /**
46
+ * Serves one review to the browser (capability in the URL fragment) and to agent commands
47
+ * (`/agent/*`, same capability). `onIdle` fires after `idleTimeout` ms with no browser stream
48
+ * and no waiting agent; `onStop` fires after an agent stop request is answered.
49
+ */
19
50
  export async function startServer(review, options = {}) {
51
+ const { watch = true, idleTimeout = 0, onIdle = () => {}, onStop = () => {} } = options;
20
52
  const capability = randomBytes(32).toString('hex');
21
53
  const route = `/review/${encodeURIComponent(review.view().change)}`;
22
54
  let origin;
23
- let listeners = 0;
24
55
  let watchStatus = { automatic: false };
25
56
  let watcher,
26
57
  stopping = false,
27
- watcherTask = Promise.resolve();
58
+ watcherTask = Promise.resolve(),
59
+ waiter = null,
60
+ idleTimer;
28
61
  const clients = new Set();
29
62
  const broadcast = (event) => {
30
63
  for (const client of clients) {
@@ -34,6 +67,31 @@ export async function startServer(review, options = {}) {
34
67
  } else client.write(`${JSON.stringify(event)}\n`);
35
68
  }
36
69
  };
70
+ const touch = () => {
71
+ clearTimeout(idleTimer);
72
+ if (!stopping && idleTimeout > 0 && !clients.size && !waiter)
73
+ idleTimer = setTimeout(onIdle, idleTimeout);
74
+ };
75
+ const presence = () => {
76
+ broadcast({ type: 'presence', listeners: waiter ? 1 : 0 });
77
+ touch();
78
+ };
79
+ const finishWait = (target, result) => {
80
+ if (!target || waiter !== target) return;
81
+ waiter = null;
82
+ clearTimeout(target.timer);
83
+ if (!target.response.destroyed) target.response.end(JSON.stringify(result));
84
+ presence();
85
+ };
86
+ const deliver = async (target) => {
87
+ try {
88
+ const batch = await review.deliver();
89
+ if (batch) finishWait(target, { status: 'batch', batch });
90
+ else if (review.state.endedAt) finishWait(target, { status: 'ended' });
91
+ } catch (error) {
92
+ finishWait(target, { status: 'error', error: error.message });
93
+ }
94
+ };
37
95
  const reportWatch = (event) => {
38
96
  watchStatus = { automatic: event.automatic, watchError: event.error };
39
97
  broadcast(event);
@@ -46,25 +104,67 @@ export async function startServer(review, options = {}) {
46
104
  await watcher?.close();
47
105
  watcher = undefined;
48
106
  reportWatch({ type: 'watch', automatic: false });
49
- } else if (!watcher && options.watch !== false)
50
- watcher = await watchChange(review, reportWatch);
107
+ } else if (!watcher && watch) watcher = await watchChange(review, reportWatch);
51
108
  })
52
109
  .catch((error) => reportWatch({ type: 'watch', automatic: false, error: error.message }));
53
110
  return watcherTask;
54
111
  };
112
+ const refresh = () => (watcher ? watcher.retry() : review.refresh());
55
113
  const unsubscribe = review.subscribe((event) => {
56
114
  if (['refresh', 'activity', 'lifecycle'].includes(event.type)) broadcast(event);
57
- if (event.type === 'lifecycle') void syncWatcher();
115
+ if (event.type === 'lifecycle') {
116
+ void syncWatcher();
117
+ // Re-check through deliver so a final Send & End batch reaches the waiter before ended.
118
+ if (event.ended && waiter) void deliver(waiter);
119
+ }
120
+ if (event.type === 'batch' && waiter) void deliver(waiter);
58
121
  });
122
+
123
+ const agent = {
124
+ status: () => ({
125
+ status: 'active',
126
+ url,
127
+ ended: Boolean(review.state.endedAt),
128
+ listening: Boolean(waiter),
129
+ }),
130
+ refresh: async () => ({ status: 'refreshed', revision: (await refresh()).revision }),
131
+ end: async () => {
132
+ await review.mutate({ action: 'end', revision: review.state.revision });
133
+ return { status: 'ended' };
134
+ },
135
+ resume: async () => {
136
+ await review.mutate({ action: 'resume', revision: review.state.revision });
137
+ return { status: 'resumed' };
138
+ },
139
+ reply: async (input) => ({
140
+ status: 'recorded',
141
+ ...(await review.recordActivity(input.batch, input)),
142
+ }),
143
+ stop: () => {
144
+ setImmediate(onStop);
145
+ return { status: 'stopping' };
146
+ },
147
+ };
148
+
149
+ function wait(input, response) {
150
+ const timeout = input.timeout ?? 540_000;
151
+ if (!Number.isInteger(timeout) || timeout < 0 || timeout > MAX_WAIT_MS)
152
+ throw new Error(`timeout must be 0–${MAX_WAIT_MS} ms.`);
153
+ if (waiter && !input.takeover) return { status: 'listener_active' };
154
+ finishWait(waiter, { status: 'listener_replaced' });
155
+ response.writeHead(200, { ...headers, 'Content-Type': 'application/json; charset=utf-8' });
156
+ const target = { response };
157
+ waiter = target;
158
+ presence();
159
+ response.once('close', () => finishWait(target, { status: 'disconnected' }));
160
+ // Queued feedback wins over a zero timeout, so the timer starts after the first delivery check.
161
+ void deliver(target).then(() => {
162
+ if (waiter === target)
163
+ target.timer = setTimeout(() => finishWait(target, { status: 'timeout' }), timeout);
164
+ });
165
+ }
166
+
59
167
  const server = http.createServer(async (request, response) => {
60
- const headers = {
61
- 'Content-Security-Policy':
62
- "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
63
- 'X-Content-Type-Options': 'nosniff',
64
- 'X-Frame-Options': 'DENY',
65
- 'Referrer-Policy': 'no-referrer',
66
- 'Cache-Control': 'no-store',
67
- };
68
168
  const send = (code, data, type = 'application/json') => {
69
169
  response.writeHead(code, { ...headers, 'Content-Type': `${type}; charset=utf-8` });
70
170
  response.end(type === 'application/json' ? JSON.stringify(data) : data);
@@ -100,10 +200,14 @@ export async function startServer(review, options = {}) {
100
200
  'Content-Type': 'application/x-ndjson; charset=utf-8',
101
201
  });
102
202
  response.write(
103
- `${JSON.stringify({ type: 'ready', revision: review.state.revision, listeners, ...watchStatus })}\n`,
203
+ `${JSON.stringify({ type: 'ready', revision: review.state.revision, listeners: waiter ? 1 : 0, ...watchStatus })}\n`,
104
204
  );
105
205
  clients.add(response);
106
- response.once('close', () => clients.delete(response));
206
+ touch();
207
+ response.once('close', () => {
208
+ clients.delete(response);
209
+ touch();
210
+ });
107
211
  return;
108
212
  }
109
213
  if (request.method === 'GET' && url.pathname === `${route}/state`)
@@ -117,37 +221,33 @@ export async function startServer(review, options = {}) {
117
221
  url.pathname.startsWith(`${route}/batch/`) && url.pathname.slice(`${route}/batch/`.length);
118
222
  if (request.method === 'GET' && batchMatch && /^[a-f0-9-]{36}$/.test(batchMatch))
119
223
  return send(200, { markdown: feedbackMarkdown(feedback(review.state, batchMatch)) });
224
+ const operation = url.pathname.startsWith('/agent/') && url.pathname.slice('/agent/'.length);
225
+ const browserMutation = [`${route}/refresh`, `${route}/action`].includes(url.pathname);
120
226
  if (
121
227
  request.method !== 'POST' ||
122
- ![`${route}/refresh`, `${route}/action`].includes(url.pathname)
228
+ !(browserMutation || operation === 'wait' || Object.hasOwn(agent, operation))
123
229
  )
124
230
  return send(404, { error: 'Unknown route.' });
125
231
  if (
126
- request.headers.origin !== origin ||
232
+ (browserMutation && request.headers.origin !== origin) ||
127
233
  request.headers['content-type'] !== 'application/json'
128
234
  )
129
235
  return send(403, { error: 'Mutation requires this review origin and JSON.' });
130
- let bytes = 0;
131
- const chunks = [];
132
- for await (const chunk of request) {
133
- bytes += chunk.length;
134
- if (bytes > 65536) {
135
- send(413, { error: 'Request exceeds 64 KiB.' });
136
- request.resume();
137
- return;
138
- }
139
- chunks.push(chunk);
140
- }
141
- const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
142
- if (!input || typeof input !== 'object' || Array.isArray(input))
143
- throw new Error('Expected a JSON object.');
144
- if (url.pathname.endsWith('/refresh')) {
145
- const state = watcher ? await watcher.retry() : await review.refresh();
146
- return send(200, { ...state, ...watchStatus });
236
+ const input = await readJson(request);
237
+ if (operation === 'wait') {
238
+ const refused = wait(input, response);
239
+ if (refused) send(200, refused);
240
+ return;
147
241
  }
242
+ if (operation) return send(200, await agent[operation](input));
243
+ if (url.pathname.endsWith('/refresh'))
244
+ return send(200, { ...(await refresh()), ...watchStatus });
148
245
  return send(200, await review.mutate(input));
149
246
  } catch (error) {
150
- send(/conflict/i.test(error.message) ? 409 : 400, { error: error.message });
247
+ if (response.headersSent) return;
248
+ send(error.statusCode || (/conflict/i.test(error.message) ? 409 : 400), {
249
+ error: error.message,
250
+ });
151
251
  }
152
252
  });
153
253
  server.requestTimeout = 20000;
@@ -159,34 +259,20 @@ export async function startServer(review, options = {}) {
159
259
  });
160
260
  origin = `http://127.0.0.1:${server.address().port}`;
161
261
  const url = `${origin}${route}#${capability}`;
162
- let control;
163
- try {
164
- control = await startControl(
165
- review,
166
- url,
167
- (count) => {
168
- listeners = count;
169
- broadcast({ type: 'presence', listeners });
170
- },
171
- () => (watcher ? watcher.retry() : review.refresh()),
172
- );
173
- } catch (error) {
174
- unsubscribe();
175
- server.closeAllConnections();
176
- server.close();
177
- throw error;
178
- }
179
262
  await syncWatcher();
263
+ touch();
180
264
  return {
181
265
  url,
182
266
  origin,
183
267
  route,
184
268
  async close() {
185
269
  stopping = true;
270
+ clearTimeout(idleTimer);
186
271
  unsubscribe();
272
+ finishWait(waiter, { status: 'stopped' });
273
+ await new Promise((resolve) => setImmediate(resolve));
187
274
  await watcherTask;
188
275
  await watcher?.close();
189
- await control.close();
190
276
  for (const client of clients) client.end();
191
277
  clients.clear();
192
278
  server.closeAllConnections();
@@ -198,8 +284,8 @@ export async function startServer(review, options = {}) {
198
284
  };
199
285
  }
200
286
 
287
+ const openers = { darwin: 'open', linux: 'xdg-open' };
201
288
  export async function openBrowser(url, run = promisify(execFile), platform = process.platform) {
202
- if (platform !== 'darwin')
203
- throw new Error('Automatic opening is verified only on macOS; use the printed URL.');
204
- await run('open', [url], { timeout: 10000, maxBuffer: 65536 });
289
+ if (!openers[platform]) throw new Error('Automatic opening supports macOS and Linux only.');
290
+ await run(openers[platform], [url], { timeout: 10000, maxBuffer: 65536 });
205
291
  }
package/src/storage.js CHANGED
@@ -1,17 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { chmod, lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
3
+ import { chmod, link, lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { hash, within } from './discovery.js';
7
- import { readLimited } from './files.js';
7
+ import { readFileLimited, readLimited } from './files.js';
8
8
 
9
9
  export function stateDirectory() {
10
10
  if (process.env.SPECTATER_STATE_DIR) return path.resolve(process.env.SPECTATER_STATE_DIR);
11
11
  if (process.platform === 'darwin')
12
12
  return path.join(os.homedir(), 'Library', 'Application Support', 'SpecTater');
13
- if (process.platform === 'win32')
14
- return path.join(process.env.LOCALAPPDATA || os.homedir(), 'SpecTater');
15
13
  return path.join(
16
14
  process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'),
17
15
  'spectater',
@@ -56,6 +54,11 @@ function validate(state, id) {
56
54
  (typeof state.endedAt !== 'string' || !Number.isFinite(Date.parse(state.endedAt)))
57
55
  )
58
56
  throw new Error('Corrupt review completion metadata.');
57
+ if (
58
+ state.endRequestedAt !== undefined &&
59
+ (typeof state.endRequestedAt !== 'string' || !Number.isFinite(Date.parse(state.endRequestedAt)))
60
+ )
61
+ throw new Error('Corrupt review completion metadata.');
59
62
  for (const [key, snapshot] of Object.entries(state.snapshots)) {
60
63
  if (
61
64
  typeof snapshot.source !== 'string' ||
@@ -97,7 +100,10 @@ function validate(state, id) {
97
100
  typeof b.id !== 'string' ||
98
101
  b.sequence !== i + 1 ||
99
102
  typeof b.submittedAt !== 'string' ||
100
- !Array.isArray(b.entries)
103
+ !Array.isArray(b.entries) ||
104
+ (b.message !== undefined &&
105
+ (typeof b.message !== 'string' || !b.message.trim() || b.message.length > 10000)) ||
106
+ (!b.entries.length && b.message === undefined)
101
107
  )
102
108
  throw new Error('Corrupt feedback batch. Restore a valid copy.');
103
109
  });
@@ -212,6 +218,25 @@ export async function loadState(directory, id) {
212
218
  }
213
219
  }
214
220
 
221
+ export function alive(pid) {
222
+ try {
223
+ process.kill(pid, 0);
224
+ return true;
225
+ } catch (error) {
226
+ return error.code === 'EPERM';
227
+ }
228
+ }
229
+
230
+ // A lock is abandoned only when it names a process that no longer exists.
231
+ async function abandoned(lock) {
232
+ try {
233
+ const { pid } = JSON.parse(new TextDecoder().decode(await readFileLimited(lock, 1024)));
234
+ return Number.isSafeInteger(pid) && pid > 0 && !alive(pid);
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
215
240
  export async function openStore(review, directory = stateDirectory()) {
216
241
  directory = path.resolve(directory);
217
242
  if (directory === review.root || within(review.root, directory))
@@ -232,9 +257,29 @@ export async function openStore(review, directory = stateDirectory()) {
232
257
  try {
233
258
  lockHandle = await open(lock, 'wx', 0o600);
234
259
  } catch (error) {
235
- throw new Error(
236
- `Review writer lock unavailable (${error.code}). Stop the other writer; after a crash inspect and explicitly remove the lock. No lock was stolen.`,
260
+ const unavailable = new Error(
261
+ `Review writer lock unavailable (${error.code}). Another SpecTater process owns this review.`,
237
262
  );
263
+ if (error.code !== 'EEXIST' || !(await abandoned(lock))) throw unavailable;
264
+ // Move the lock aside before judging it, so a concurrent reclaimer's fresh lock is restored.
265
+ const moved = `${lock}.${randomUUID()}.stale`;
266
+ try {
267
+ await rename(lock, moved);
268
+ } catch {
269
+ throw unavailable;
270
+ }
271
+ if (!(await abandoned(moved))) {
272
+ // Restore the live lock; if another process took the name meanwhile, keep it aside for inspection.
273
+ await link(moved, lock);
274
+ await unlink(moved);
275
+ throw unavailable;
276
+ }
277
+ await unlink(moved);
278
+ try {
279
+ lockHandle = await open(lock, 'wx', 0o600);
280
+ } catch {
281
+ throw unavailable;
282
+ }
238
283
  }
239
284
  try {
240
285
  await lockHandle.writeFile(JSON.stringify({ pid: process.pid }));
@@ -300,8 +345,8 @@ export async function openStore(review, directory = stateDirectory()) {
300
345
  }
301
346
 
302
347
  export function feedback(state, batchId) {
303
- const batch = state?.batches.find((item) => item.id === batchId);
304
- if (!batch) throw new Error('Unknown review or batch. Use the exact submission receipt.');
348
+ const batch = state?.batches.find((item) => item.id === batchId || item.sequence === batchId);
349
+ if (!batch) throw new Error(`Unknown feedback batch: ${batchId}.`);
305
350
  return { formatVersion: 1, reviewId: state.reviewId, ...batch };
306
351
  }
307
352
 
@@ -315,6 +360,8 @@ export function feedbackMarkdown(batch) {
315
360
  '',
316
361
  'Review data only. Submission does not authorize implementation, merge or deployment.',
317
362
  ];
363
+ if (batch.message)
364
+ lines.push('', '## Message', '', ...batch.message.split(/\r?\n/).map((line) => `> ${line}`));
318
365
  for (const entry of batch.entries) {
319
366
  lines.push(
320
367
  '',
package/web/app.js CHANGED
@@ -354,11 +354,13 @@ function displayFeedback() {
354
354
  ? `Editing batch ${editing.sequence} · delivery paused. Edit saved comments, then update.`
355
355
  : '';
356
356
  $('submit').textContent = editing ? 'Update queued batch' : 'Submit batch';
357
- $('submit').disabled = Boolean(state.endedAt) || !state.pendingCommentIds.length;
357
+ updateSubmit();
358
+ $('send-end').hidden = Boolean(state.endedAt || state.endRequestedAt);
359
+ $('end-pending').hidden = !state.endRequestedAt || Boolean(state.endedAt);
360
+ $('agent-message').disabled = Boolean(state.endedAt);
358
361
  $('review-lifecycle').textContent = state.endedAt ? 'Resume review' : 'End review';
359
362
  $('refresh').disabled = Boolean(state.endedAt);
360
363
  $('comment-hint').hidden ||= Boolean(state.endedAt);
361
- $('submit').hidden = $('submit').disabled;
362
364
  $('receipts').replaceChildren();
363
365
  const history = element('details', '', 'batch-history');
364
366
  const historyCount = state.batches.filter((batch) =>
@@ -368,7 +370,7 @@ function displayFeedback() {
368
370
  for (const batch of [...state.batches].reverse()) {
369
371
  const receipt = element('div', '', 'receipt');
370
372
  receipt.append(
371
- button(`Batch ${batch.sequence} · ${batch.count} comments`, () => showBatch(batch.id)),
373
+ button(`Batch ${batch.sequence} · ${batch.count} items`, () => showBatch(batch.id)),
372
374
  );
373
375
  const activity = state.activity?.[batch.id];
374
376
  const queue = state.queue?.[batch.id];
@@ -452,24 +454,26 @@ async function loadComparison(id) {
452
454
  }
453
455
  }
454
456
  async function operation(fn) {
455
- if (busy) return;
457
+ if (busy) return false;
456
458
  busy = true;
457
459
  document.body.setAttribute('aria-busy', 'true');
458
460
  try {
459
461
  await fn();
462
+ return true;
460
463
  } catch (error) {
461
464
  announce(error.message, true);
462
465
  if ($('comment-popover').matches(':popover-open')) {
463
466
  $('composer-error').textContent = error.message;
464
467
  $('composer-error').hidden = false;
465
468
  }
469
+ return false;
466
470
  } finally {
467
471
  busy = false;
468
472
  document.body.setAttribute('aria-busy', 'false');
469
473
  }
470
474
  }
471
475
  async function action(input) {
472
- await operation(async () => {
476
+ return operation(async () => {
473
477
  acceptState(await api('action', { ...input, revision: state.revision }));
474
478
  if (input.action === 'comment' || input.action === 'edit') {
475
479
  $('comment').value = '';
@@ -478,6 +482,7 @@ async function action(input) {
478
482
  persistDraft();
479
483
  }
480
484
  if (input.action === 'delete') undoDeletedId = input.id;
485
+ if (input.action === 'submit') $('agent-message').value = '';
481
486
  if (input.action === 'end') closeComposer();
482
487
  display();
483
488
  announce(
@@ -486,7 +491,11 @@ async function action(input) {
486
491
  : input.action === 'resume'
487
492
  ? 'Review resumed.'
488
493
  : input.action === 'submit'
489
- ? 'Batch saved.'
494
+ ? state.endedAt
495
+ ? 'Review ended.'
496
+ : input.end
497
+ ? 'Batch saved. The review ends once the agent receives it.'
498
+ : 'Batch saved.'
490
499
  : input.action === 'cancel-batch'
491
500
  ? 'Submission cancelled. Comments kept.'
492
501
  : input.action === 'edit-batch'
@@ -495,14 +504,16 @@ async function action(input) {
495
504
  ? 'Comment deleted.'
496
505
  : 'Saved.',
497
506
  );
498
- if (input.action === 'submit') await showBatch(state.batches.at(-1).id, false);
507
+ if (input.action === 'submit' && state.batches.length)
508
+ await showBatch(state.batches.at(-1).id, false);
499
509
  });
500
510
  }
501
511
  async function showBatch(id, open = true) {
502
512
  try {
503
513
  $('export').value = (await api(`batch/${id}`)).markdown;
514
+ const batch = state.batches.find((item) => item.id === id);
504
515
  $('retrieval-command').textContent =
505
- `spectater feedback --review ${state.reviewId} --batch ${id} --format markdown --history`;
516
+ `spectater feedback ${state.change} --batch ${batch.sequence} --format markdown`;
506
517
  $('export-details').hidden = false;
507
518
  $('export-details').open = open;
508
519
  } catch (error) {
@@ -599,10 +610,14 @@ document.addEventListener('keyup', (event) => {
599
610
  )
600
611
  captureSelection();
601
612
  });
613
+ function saveComment() {
614
+ if (editingId) return action({ action: 'edit', id: editingId, body: $('comment').value });
615
+ if (selection) return action({ action: 'comment', ...selection, body: $('comment').value });
616
+ return false;
617
+ }
602
618
  $('comment-form').addEventListener('submit', (event) => {
603
619
  event.preventDefault();
604
- if (editingId) action({ action: 'edit', id: editingId, body: $('comment').value });
605
- else if (selection) action({ action: 'comment', ...selection, body: $('comment').value });
620
+ void saveComment();
606
621
  });
607
622
  $('comment').addEventListener('input', persistDraft);
608
623
  $('comment-popover').addEventListener('toggle', updateDraftButton);
@@ -615,7 +630,32 @@ $('review-lifecycle').addEventListener('click', () => {
615
630
  if (!state.endedAt) editingId = undefined;
616
631
  void action({ action: state.endedAt ? 'resume' : 'end' });
617
632
  });
618
- $('submit').addEventListener('click', () => action({ action: 'submit' }));
633
+ function updateSubmit() {
634
+ $('submit').disabled =
635
+ Boolean(state.endedAt) || !(state.pendingCommentIds.length || $('agent-message').value.trim());
636
+ }
637
+ function submit(end = false) {
638
+ const message = $('agent-message').value.trim();
639
+ return action({ action: 'submit', ...(message ? { message } : {}), ...(end ? { end } : {}) });
640
+ }
641
+ $('submit').addEventListener('click', () => submit());
642
+ $('send-end').addEventListener('click', () => {
643
+ if ($('comment').value)
644
+ return announce('Save or clear the unsaved draft before ending the review.', true);
645
+ void submit(true);
646
+ });
647
+ $('agent-message').addEventListener('input', updateSubmit);
648
+ $('agent-message').addEventListener('keydown', (event) => {
649
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
650
+ event.preventDefault();
651
+ if (!$('submit').disabled) void submit();
652
+ });
653
+ $('comment').addEventListener('keydown', async (event) => {
654
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
655
+ event.preventDefault();
656
+ if ($('save').disabled || !(await saveComment())) return;
657
+ if ((event.metaKey || event.ctrlKey) && !$('submit').disabled) await submit();
658
+ });
619
659
  $('refresh').addEventListener('click', () =>
620
660
  operation(async () => {
621
661
  acceptState(await api('refresh', {}));
package/web/index.html CHANGED
@@ -98,7 +98,20 @@
98
98
  </details>
99
99
  <div class="submission">
100
100
  <p id="queue-editing" class="note" hidden></p>
101
- <button id="submit" type="button" hidden>Submit batch</button>
101
+ <label for="agent-message">Message to agent</label>
102
+ <textarea
103
+ id="agent-message"
104
+ rows="2"
105
+ maxlength="10000"
106
+ placeholder="Optional. Enter sends; Shift+Enter adds a line."
107
+ ></textarea>
108
+ <div class="submit-actions">
109
+ <button id="submit" type="button" disabled>Submit batch</button
110
+ ><button id="send-end" type="button">Send &amp; End</button>
111
+ </div>
112
+ <p id="end-pending" class="note" hidden>
113
+ Ending after the agent receives the final batch.
114
+ </p>
102
115
  <div id="receipts"></div>
103
116
  <button id="review-lifecycle" type="button">End review</button>
104
117
  <details id="export-details" hidden>
@@ -135,7 +148,7 @@
135
148
  rows="3"
136
149
  maxlength="10000"
137
150
  required
138
- placeholder="Add a comment…"
151
+ placeholder="Add a comment… Enter saves; Cmd/Ctrl+Enter saves and submits."
139
152
  ></textarea>
140
153
  <button id="save" type="submit" disabled>Save comment</button>
141
154
  </form>
package/web/style.css CHANGED
@@ -411,6 +411,18 @@ textarea::placeholder {
411
411
  .submission {
412
412
  margin-top: var(--space-6);
413
413
  }
414
+ .submission label {
415
+ display: block;
416
+ font-size: var(--text-xs);
417
+ color: var(--muted);
418
+ margin-bottom: var(--space-1);
419
+ }
420
+ .submit-actions {
421
+ display: flex;
422
+ flex-wrap: wrap;
423
+ gap: var(--space-2);
424
+ margin: var(--space-2) 0;
425
+ }
414
426
  .receipt {
415
427
  margin: var(--space-3) 0;
416
428
  }
package/SECURITY.md DELETED
@@ -1,11 +0,0 @@
1
- # Security
2
-
3
- SpecTater is a local tool. Verified platforms and limits are documented in README.md.
4
-
5
- Do not disclose vulnerabilities, secrets or private artifact content in a public issue. Use GitHub's private vulnerability reporting for this repository when available. Otherwise contact the maintainer privately through the contact information on their GitHub profile before sharing technical details.
6
-
7
- The implementation runs locally on loopback and serves only the selected change manifest. A per-process capability authorizes review requests; Host/Origin checks and CSP restrict browser access. Raw HTML remains escaped, external images do not load, and the reader never writes canonical Markdown. These boundaries do not isolate the process from local users who can modify it or its files.
8
-
9
- Review snapshots may contain private source. Storage is owner-only outside the repository, remains until explicit removal, and is never uploaded by SpecTater. Keep capability URLs and review-state files private. Do not commit state, browser profiles or exported private feedback. File, request and state size limits fail visibly; no automatic retention cleanup runs.
10
-
11
- Contract and browser tests exercise filesystem escape, active Markdown, foreign requests, bounded native reads, queue/lifecycle races and persistence failures. See [the security pass](docs/security-review.md) for the source-review scope and the independent release-review finding. Automated tests do not replace review.