@oddessentials/agent-guild 0.29.0 → 0.30.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 CHANGED
@@ -160,8 +160,10 @@ vX.Y.Z** switches over when you are ready. The version badge opens
160
160
 
161
161
  **Keep notes at hand.** **Notes** in the top bar opens a notepad for prompts,
162
162
  commands and to-dos in the side panel, next to your terminals. It saves up to
163
- 100,000 characters as you type and keeps them in this browser, never on the
164
- manager; every tab of the page shows the same notes.
163
+ 100,000 characters as you type. The same notes follow every browser signed in
164
+ to this manager, including one opened through remote access. This browser
165
+ keeps a copy too: if the manager is unreachable, the line under the title
166
+ says the notes are saved in this browser only, and they sync when it is back.
165
167
 
166
168
  **GitHub beside your work.** **GitHub** in the top bar opens the side panel.
167
169
  Sign in to one or more GitHub accounts, then search the repositories of all
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,6 +45,8 @@ export const paths = {
45
45
  get accounts() { return path.join(dataDir(), 'accounts'); },
46
46
  get github() { return path.join(dataDir(), 'github'); },
47
47
  get remoteAccess() { return path.join(dataDir(), 'remote-access.json'); },
48
+ /** The shared notepad. A missing file means it was never stored. */
49
+ get notes() { return path.join(dataDir(), 'notes.json'); },
48
50
  get log() { return path.join(dataDir(), 'manager.log'); },
49
51
  /** Launchers for agent-guild-report, put first on every session's PATH. */
50
52
  get shims() { return path.join(dataDir(), 'bin'); },
@@ -13,6 +13,7 @@ import { NewsFeed } from './news.mjs';
13
13
  import { Changelog } from './changelog.mjs';
14
14
  import { GitHub } from './github.mjs';
15
15
  import { createManagerServer } from './server.mjs';
16
+ import { createNotesStore } from './notes.mjs';
16
17
  import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
17
18
  import { SelfUpdate } from './self-update.mjs';
18
19
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
@@ -138,6 +139,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
138
139
  extraHosts: remoteSettings.config.access.hosts,
139
140
  extraOrigins: remoteSettings.config.access.origins,
140
141
  remoteAccess,
142
+ notes: createNotesStore(paths.notes),
141
143
  launcher: launcherPath(),
142
144
  onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
143
145
  });
@@ -0,0 +1,120 @@
1
+ // One notepad for every browser signed in to this manager.
2
+ // A missing file means notes were never stored. An empty string is a notepad
3
+ // the user cleared; those two must stay distinct so an old browser cannot
4
+ // put cleared notes back.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+
10
+ export const NOTES_LIMIT = 100000;
11
+ /** Larger than the API's usual 64 KiB body, and enough for a full notepad after JSON escaping. */
12
+ export const NOTES_BODY_LIMIT = 1024 * 1024;
13
+ const NOTES_FILE_LIMIT = 2 * 1024 * 1024;
14
+
15
+ function notesError(code, message, status, notes) {
16
+ // The store reports an unreadable file once, so the server does not log every request that hits it.
17
+ const error = Object.assign(new Error(message), { code, status, logged: code === 'notes_unreadable' });
18
+ if (notes) error.notes = notes;
19
+ return error;
20
+ }
21
+
22
+ /** @returns {{ revision: string, text: string } | null} null when the file is not there yet */
23
+ function readNotes(file) {
24
+ let stat;
25
+ try { stat = fs.statSync(file); } catch (error) {
26
+ if (error.code === 'ENOENT') return null;
27
+ throw notesError('notes_unreadable', 'Notes could not be read.', 500);
28
+ }
29
+ if (!stat.isFile() || stat.size > NOTES_FILE_LIMIT) throw notesError('notes_unreadable', 'Notes could not be read.', 500);
30
+ let value;
31
+ try { value = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
32
+ throw notesError('notes_unreadable', 'Notes could not be read.', 500);
33
+ }
34
+ if (!value || value.version !== 1 || typeof value.revision !== 'string' || !value.revision || value.revision.length > 100
35
+ || typeof value.text !== 'string' || value.text.length > NOTES_LIMIT) {
36
+ throw notesError('notes_unreadable', 'Notes could not be read.', 500);
37
+ }
38
+ return { revision: value.revision, text: value.text };
39
+ }
40
+
41
+ function writeNotes(file, notes) {
42
+ const contents = `${JSON.stringify({ version: 1, revision: notes.revision, text: notes.text }, null, 2)}\n`;
43
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
44
+ const next = `${file}.${randomUUID()}.tmp`;
45
+ let descriptor;
46
+ try {
47
+ descriptor = fs.openSync(next, 'wx', 0o600);
48
+ fs.writeFileSync(descriptor, contents);
49
+ fs.fsyncSync(descriptor);
50
+ fs.closeSync(descriptor);
51
+ descriptor = undefined;
52
+ fs.renameSync(next, file);
53
+ } finally {
54
+ if (descriptor !== undefined) fs.closeSync(descriptor);
55
+ try { fs.unlinkSync(next); } catch { /* renamed, or never created */ }
56
+ }
57
+ try { fs.chmodSync(file, 0o600); } catch { /* not supported on Windows */ }
58
+ }
59
+
60
+ /**
61
+ * @param {string} [file] omit for an in-memory store (tests). The real manager passes its notes file.
62
+ * @param {{ warn?: (message: string) => void }} [options] where the one line about an unreadable file goes
63
+ */
64
+ export function createNotesStore(file, { warn = (message) => console.warn(message) } = {}) {
65
+ let current = { revision: null, text: '' };
66
+ let loaded = !file;
67
+ let warned = false;
68
+
69
+ /** An unreadable file is read again next time, so fixing or removing it needs no restart. */
70
+ function ensure() {
71
+ if (loaded) return;
72
+ let read;
73
+ try { read = readNotes(file); } catch (error) {
74
+ if (!warned) warn(`[notes] ${file} cannot be read, so notes are not shared until it is fixed or removed.`);
75
+ warned = true;
76
+ throw error;
77
+ }
78
+ warned = false;
79
+ current = read ?? { revision: null, text: '' };
80
+ loaded = true;
81
+ }
82
+
83
+ function snapshot() {
84
+ ensure();
85
+ return { revision: current.revision, text: current.text };
86
+ }
87
+
88
+ function save(body) {
89
+ ensure();
90
+ if (!body || typeof body.text !== 'string') throw notesError('bad_notes', 'Notes must be text.', 400);
91
+ if (body.text.length > NOTES_LIMIT) {
92
+ throw notesError('notes_too_long', `Notes hold up to ${NOTES_LIMIT.toLocaleString('en-US')} characters.`, 400);
93
+ }
94
+ const revision = body.revision ?? null;
95
+ if (revision !== null && (typeof revision !== 'string' || !revision || revision.length > 100)) {
96
+ throw notesError('bad_notes', 'Notes must be text.', 400);
97
+ }
98
+ if (revision !== current.revision) {
99
+ throw notesError('stale_notes', 'Notes changed in another browser.', 409, { revision: current.revision, text: current.text });
100
+ }
101
+ const next = { revision: randomUUID(), text: body.text };
102
+ if (file) writeNotes(file, next);
103
+ current = next;
104
+ loaded = true;
105
+ return { revision: next.revision, text: next.text };
106
+ }
107
+
108
+ return {
109
+ snapshot,
110
+ save,
111
+ /** `{ known: false }` when the file cannot be read, so a client does not treat that as an empty notepad. */
112
+ helloRevision() {
113
+ try {
114
+ return { known: true, revision: snapshot().revision };
115
+ } catch {
116
+ return { known: false };
117
+ }
118
+ },
119
+ };
120
+ }
@@ -12,6 +12,7 @@ import { createViews } from './github-views.mjs';
12
12
  import { createFolderOpener } from './folder-opener.mjs';
13
13
  import { createFolderBrowser } from './folder-browser.mjs';
14
14
  import { normalizeAccess } from './access-policy.mjs';
15
+ import { NOTES_BODY_LIMIT, createNotesStore } from './notes.mjs';
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
18
  const API = '/api/v1';
@@ -63,7 +64,7 @@ class HttpError extends Error {
63
64
  }
64
65
  }
65
66
 
66
- function readJsonBody(req) {
67
+ function readJsonBody(req, max = MAX_BODY) {
67
68
  return new Promise((resolve, reject) => {
68
69
  let size = 0;
69
70
  const chunks = [];
@@ -71,7 +72,7 @@ function readJsonBody(req) {
71
72
  req.on('data', (chunk) => {
72
73
  if (tooLarge) return; // drain the rest so the 413 response can be read
73
74
  size += chunk.length;
74
- if (size > MAX_BODY) {
75
+ if (size > max) {
75
76
  tooLarge = true;
76
77
  chunks.length = 0;
77
78
  reject(new HttpError(413, 'request body too large', 'too_large'));
@@ -113,6 +114,8 @@ export function createManagerServer({
113
114
  extraHosts = [],
114
115
  extraOrigins = [],
115
116
  remoteAccess = null,
117
+ /** In-memory when omitted, so a test server never reads the user's notes file. */
118
+ notes = createNotesStore(),
116
119
  folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
117
120
  folderBrowser = createFolderBrowser(),
118
121
  /** The double-click launcher file for this platform, or null when the package carries none. */
@@ -252,6 +255,15 @@ export function createManagerServer({
252
255
 
253
256
  requireAuth(req, url);
254
257
 
258
+ if (route === '/notes' && method === 'GET') {
259
+ return sendJson(res, 200, { notes: notes.snapshot() });
260
+ }
261
+ if (route === '/notes' && method === 'PUT') {
262
+ const saved = notes.save(await readJsonBody(req, NOTES_BODY_LIMIT));
263
+ broadcast({ type: 'notes.updated', notes: saved });
264
+ return sendJson(res, 200, { notes: saved });
265
+ }
266
+
255
267
  if (remoteAccess && route === '/remote-access' && method === 'GET') {
256
268
  return sendJson(res, 200, { remoteAccess: remoteAccess.snapshot() });
257
269
  }
@@ -486,12 +498,13 @@ export function createManagerServer({
486
498
  else serveStatic(req, res, url.pathname);
487
499
  } catch (err) {
488
500
  const status = err.status || 500;
489
- if (status >= 500) console.error('[server]', err);
501
+ if (status >= 500 && !err.logged) console.error('[server]', err);
490
502
  if (!res.headersSent) {
491
503
  const error = { code: err.code || 'error', message: err.message };
492
504
  if (err.running !== undefined) error.running = err.running;
493
505
  if (err.pending !== undefined) error.pending = err.pending;
494
506
  if (err.target !== undefined) error.target = err.target;
507
+ if (err.notes !== undefined) error.notes = err.notes;
495
508
  sendJson(res, status, { error });
496
509
  }
497
510
  }
@@ -539,7 +552,11 @@ export function createManagerServer({
539
552
 
540
553
  function handleEvents(ws, req) {
541
554
  eventClients.add(ws);
542
- safeSend(ws, { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), sessions: manager.list() });
555
+ const hello = { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), sessions: manager.list() };
556
+ const notesHello = notes.helloRevision();
557
+ if (notesHello.known) hello.notesRevision = notesHello.revision;
558
+ else hello.notesUnreadable = true;
559
+ safeSend(ws, hello);
543
560
  ws.on('close', () => eventClients.delete(ws));
544
561
  ws.on('message', () => { /* events socket is server -> client only */ });
545
562
  }
package/web/app.js CHANGED
@@ -31,6 +31,7 @@ const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
31
31
  const SOUND_KEY = 'agentGuild.sound';
32
32
  const VOICE_KEY = 'agentGuild.voice';
33
33
  const NOTES_KEY = 'agentGuild.notes';
34
+ const NOTES_REV_KEY = 'agentGuild.notesRevision';
34
35
  const DOCK_KEY = 'agentGuild.dock';
35
36
  const DOCK_WIDTH_KEY = 'agentGuild.dockWidth';
36
37
  const PANES_KEY = 'agentGuild.panes';
@@ -366,35 +367,63 @@ function moveInMenu(e) {
366
367
  /** Notes share the page's storage with every other setting, so they stay far below the browser's limit for it. */
367
368
  const NOTES_LIMIT = 100000;
368
369
  const NOTES_STATUS = {
369
- saved: 'Saved in this browser as you type',
370
+ saved: 'Saved and synced across your browsers',
371
+ local: 'Saved in this browser only. Syncs when the manager is back.',
372
+ damaged: 'Not syncing: the manager’s notes file is damaged. Saved in this browser only.',
370
373
  long: `Not saved: notes hold up to ${NOTES_LIMIT.toLocaleString('en-US')} characters. Shorten them to save.`,
371
374
  refused: 'Not saved: the browser’s storage for this page is full or turned off. Copy what you need before you close the page.',
372
375
  };
376
+ /** How long a burst of typing waits before one save to the manager. */
377
+ const NOTES_PUSH_MS = 400;
378
+ /** Browsers drop a keepalive request larger than 64 KiB, so a bigger notepad waits for the next open. */
379
+ const NOTES_KEEPALIVE_BYTES = 60 * 1024;
373
380
  /**
374
381
  * `saved`: the notes as this page last read or wrote them in storage. `status`: whether the text in the
375
- * panel is saved, too `long` or `refused`; `shown`: the status the line under the title shows.
382
+ * panel is saved, too `long` or `refused`; `sync`: whether the manager has it (`ok`), could not be reached
383
+ * (`local`) or cannot read its notes file (`damaged`); `shown`: the status the line under the title shows.
384
+ * `base`: the manager revision this page last saved from. `acked`: the text of that revision.
385
+ * `flight`: the save to the manager under way. Text typed meanwhile is sent when it finishes.
376
386
  */
377
- const notesView = { saved: '', status: 'saved', shown: 'saved' };
387
+ const notesView = { saved: '', status: 'saved', sync: 'ok', shown: 'saved', base: null, acked: null, timer: 0, flight: null };
378
388
 
379
389
  /** Shows notes another tab saved since this page last read or wrote them. Saved notes win over text this page could not save. */
380
390
  function refreshNotes() {
381
391
  let text;
382
392
  // Unlike load(), a read that fails is not taken for empty notes.
383
393
  try { text = localStorage.getItem(NOTES_KEY) ?? ''; } catch { return; }
384
- if (text === notesView.saved) return;
385
- notesView.saved = text;
386
- notesView.status = 'saved';
387
- $('notes-text').value = text;
394
+ const revision = load(NOTES_REV_KEY);
395
+ if (text === notesView.saved && revision === notesView.base) return;
396
+ if (text !== notesView.saved) {
397
+ notesView.saved = text;
398
+ notesView.status = 'saved';
399
+ $('notes-text').value = text;
400
+ }
401
+ // The revision is kept only while the stored text is the one it names, so that text is on the manager.
402
+ if (revision) notesView.acked = text;
403
+ if (revision !== notesView.base) notesView.base = revision;
388
404
  renderNotesStatus();
389
405
  guardLeaving();
390
406
  }
391
407
 
392
- /** Saves the notes on every change; emptying them removes the saved copy. */
408
+ /** Saves the notes on every change; emptying them removes the saved copy. This stays in the browser. */
393
409
  function saveNotes() {
394
410
  const text = $('notes-text').value;
395
- if (text.length > NOTES_LIMIT) notesView.status = 'long';
396
- else if (!save(NOTES_KEY, text || null)) notesView.status = 'refused';
397
- else {
411
+ if (text.length > NOTES_LIMIT) {
412
+ notesView.status = 'long';
413
+ renderNotesStatus();
414
+ guardLeaving();
415
+ return;
416
+ }
417
+ // A reload can tell these keystrokes have not reached the manager: the stored revision goes first, so
418
+ // stored text never sits beside a revision it does not match. The revision in memory stays, for the save.
419
+ // An empty panel is probed with the same text, and that must not look like a new edit.
420
+ const revision = text !== notesView.saved ? load(NOTES_REV_KEY) : null;
421
+ if (revision) save(NOTES_REV_KEY, null);
422
+ if (!save(NOTES_KEY, text || null)) {
423
+ // The stored text did not change, so it still matches its revision.
424
+ if (revision) save(NOTES_REV_KEY, revision);
425
+ notesView.status = 'refused';
426
+ } else {
398
427
  notesView.status = 'saved';
399
428
  notesView.saved = text;
400
429
  }
@@ -404,16 +433,30 @@ function saveNotes() {
404
433
 
405
434
  /** The line under the title is a live region, so it is rewritten only when the status changes. */
406
435
  function renderNotesStatus() {
407
- if (notesView.shown === notesView.status) return;
408
- notesView.shown = notesView.status;
436
+ // A problem saving in this browser outranks one reaching the manager.
437
+ const shown = notesView.status === 'saved' && notesView.sync !== 'ok' ? notesView.sync : notesView.status;
438
+ if (notesView.shown === shown) return;
439
+ notesView.shown = shown;
409
440
  const sub = $('notes-sub');
410
- sub.textContent = NOTES_STATUS[notesView.status];
411
- sub.classList.toggle('warn', notesView.status !== 'saved');
441
+ sub.textContent = NOTES_STATUS[shown];
442
+ sub.classList.toggle('warn', shown !== 'saved');
443
+ }
444
+
445
+ /** Whether the manager has the notes. Typing never changes this, so the line does not flicker. */
446
+ function setNotesSync(sync) {
447
+ if (notesView.sync === sync) return;
448
+ notesView.sync = sync;
449
+ renderNotesStatus();
450
+ }
451
+
452
+ /** A save or read that failed. A file the manager cannot read is reported as such; anything else is unreachable. */
453
+ function notesFailed(error) {
454
+ setNotesSync(error?.code === 'notes_unreadable' ? 'damaged' : 'local');
412
455
  }
413
456
 
414
457
  /** Another tab saved the notes, or cleared this page's storage. */
415
458
  function notesStored(e) {
416
- if (e.key === NOTES_KEY || e.key === null) refreshNotes();
459
+ if (e.key === NOTES_KEY || e.key === NOTES_REV_KEY || e.key === null) refreshNotes();
417
460
  }
418
461
 
419
462
  function openNotes({ focus = true } = {}) {
@@ -430,6 +473,181 @@ function toggleNotes() {
430
473
  else openNotes();
431
474
  }
432
475
 
476
+ function sharedPrefix(left, right) {
477
+ const end = Math.min(left.length, right.length);
478
+ let i = 0;
479
+ while (i < end && left.charCodeAt(i) === right.charCodeAt(i)) i += 1;
480
+ return i;
481
+ }
482
+
483
+ /** Takes the manager's notes. A selection that still sits in the unchanged prefix stays put. */
484
+ function adoptNotes(notes) {
485
+ const area = $('notes-text');
486
+ const next = notes.text;
487
+ if (typeof next !== 'string') return;
488
+ if (next !== area.value) {
489
+ const start = area.selectionStart;
490
+ const end = area.selectionEnd;
491
+ const previous = area.value;
492
+ const focused = document.activeElement === area;
493
+ area.value = next;
494
+ if (focused) {
495
+ const keep = sharedPrefix(previous, next);
496
+ if (start <= keep && end <= keep) area.setSelectionRange(start, end);
497
+ }
498
+ }
499
+ notesView.saved = next;
500
+ notesView.acked = next;
501
+ notesView.base = notes.revision ?? null;
502
+ notesView.status = 'saved';
503
+ notesView.sync = 'ok';
504
+ // Revision last, and only over text that was stored: a stored revision always names the stored text.
505
+ save(NOTES_REV_KEY, null);
506
+ if (save(NOTES_KEY, next || null)) save(NOTES_REV_KEY, notes.revision || null);
507
+ renderNotesStatus();
508
+ guardLeaving();
509
+ }
510
+
511
+ function notesPushable() {
512
+ const text = $('notes-text').value;
513
+ if (notesView.status !== 'saved' || text.length > NOTES_LIMIT) return false;
514
+ if (text === notesView.acked) return false;
515
+ if (notesView.base == null && text === '') return false;
516
+ return true;
517
+ }
518
+
519
+ function scheduleNotesPush(delay = NOTES_PUSH_MS) {
520
+ clearTimeout(notesView.timer);
521
+ notesView.timer = 0;
522
+ if (!notesPushable()) return Promise.resolve();
523
+ if (delay === 0) return pushNotes();
524
+ notesView.timer = setTimeout(() => { notesView.timer = 0; void pushNotes(); }, delay);
525
+ return Promise.resolve();
526
+ }
527
+
528
+ /** The page is being hidden or closed: send what the debounce still holds. */
529
+ function flushNotes() {
530
+ void scheduleNotesPush(0);
531
+ }
532
+
533
+ /**
534
+ * One save to the manager at a time, so two of this page's own saves can never race each other. Text typed
535
+ * while a save is under way follows it. A conflict sends this page's text once more at the manager's revision:
536
+ * the latest typing wins, and the text on screen is never replaced by an older copy.
537
+ */
538
+ function pushNotes() {
539
+ // The save under way checks for newer text when it finishes.
540
+ if (notesView.flight) return notesView.flight;
541
+ const run = (async () => {
542
+ let resent = false;
543
+ for (;;) {
544
+ if (!notesPushable()) return;
545
+ const outcome = await sendNotes($('notes-text').value);
546
+ if (outcome === 'stale' && !resent) resent = true;
547
+ else if (outcome !== 'ok') return;
548
+ }
549
+ })();
550
+ notesView.flight = run;
551
+ return run.finally(() => { notesView.flight = null; });
552
+ }
553
+
554
+ /** @returns {Promise<'ok'|'stale'|'failed'>} `stale`: the manager moved on, and `base` now names its revision. */
555
+ async function sendNotes(text) {
556
+ const body = { revision: notesView.base, text };
557
+ // A hidden page may be closing: keepalive lets the save outlive it, for bodies the browser allows.
558
+ const keepalive = document.visibilityState === 'hidden'
559
+ && new TextEncoder().encode(JSON.stringify(body)).length <= NOTES_KEEPALIVE_BYTES;
560
+ let payload;
561
+ try {
562
+ payload = (await api('PUT', '/notes', body, { keepalive })).notes;
563
+ } catch (error) {
564
+ if (error instanceof AuthError) {
565
+ showAuth(error.message);
566
+ return 'failed';
567
+ }
568
+ if (error.code !== 'stale_notes' || !error.notes || typeof error.notes.text !== 'string') {
569
+ notesFailed(error);
570
+ return 'failed';
571
+ }
572
+ // The manager already holds exactly this text, saved by another tab or an earlier request.
573
+ if (error.notes.text === $('notes-text').value) {
574
+ adoptNotes(error.notes);
575
+ return 'ok';
576
+ }
577
+ notesView.base = error.notes.revision ?? null;
578
+ return 'stale';
579
+ }
580
+ if (!payload || typeof payload.text !== 'string') {
581
+ notesFailed();
582
+ return 'failed';
583
+ }
584
+ if ($('notes-text').value === text) adoptNotes(payload);
585
+ else {
586
+ // Typing went on during the save: the newer text follows from this revision.
587
+ notesView.base = payload.revision ?? notesView.base;
588
+ setNotesSync('ok');
589
+ }
590
+ return 'ok';
591
+ }
592
+
593
+ /** Another browser's save. Our own echo matches the text already on screen and only adopts the revision. */
594
+ function applyServerNotes(notes) {
595
+ if (!notes || typeof notes.text !== 'string') return;
596
+ const area = $('notes-text');
597
+ if (notes.text === area.value) {
598
+ adoptNotes(notes);
599
+ return;
600
+ }
601
+ if (notesView.acked !== null && area.value !== notesView.acked) {
602
+ notesView.base = notes.revision ?? notesView.base;
603
+ return;
604
+ }
605
+ adoptNotes(notes);
606
+ }
607
+
608
+ /** The notes part of hello. A missing revision means this manager cannot share notes. */
609
+ async function catchUpNotes(revision, unreadable) {
610
+ if (unreadable) {
611
+ setNotesSync('damaged');
612
+ return;
613
+ }
614
+ if (revision === undefined) return;
615
+ // Hello can arrive before the first read. Load this browser's copy before deciding, unless typing has started.
616
+ if (notesView.saved === '' && $('notes-text').value === '' && notesView.acked === null) refreshNotes();
617
+ const local = $('notes-text').value;
618
+ // No file yet, or it was removed. Text this browser has is the first copy. An empty panel writes nothing.
619
+ if (revision === null) notesView.acked = local ? null : '';
620
+ if (revision === null || revision === notesView.base) {
621
+ if (notesPushable()) await scheduleNotesPush(0);
622
+ else setNotesSync('ok');
623
+ return;
624
+ }
625
+ await pullNotes();
626
+ }
627
+
628
+ /** The manager moved on while this page was away. Its copy wins unless this browser has edits it never sent. */
629
+ async function pullNotes() {
630
+ let notes;
631
+ try {
632
+ notes = (await api('GET', '/notes')).notes;
633
+ } catch (error) {
634
+ if (error instanceof AuthError) showAuth(error.message);
635
+ else notesFailed(error);
636
+ return;
637
+ }
638
+ if (!notes || typeof notes.text !== 'string') return;
639
+ const local = $('notes-text').value;
640
+ // `acked` is unknown only when this browser holds text no revision names: an edit that never reached the manager.
641
+ const unsent = local !== notes.text && (notesView.acked === null ? local !== '' : local !== notesView.acked);
642
+ if (!unsent) {
643
+ adoptNotes(notes);
644
+ return;
645
+ }
646
+ notesView.base = notes.revision ?? null;
647
+ setNotesSync('ok');
648
+ await scheduleNotesPush(0);
649
+ }
650
+
433
651
  // ---- dock -----------------------------------------------------------------
434
652
 
435
653
  const DOCK_PANELS = ['github', 'notes'];
@@ -717,9 +935,10 @@ function renderAgents(container, agents, shells = []) {
717
935
 
718
936
  class AuthError extends Error {}
719
937
 
720
- async function api(method, path, body) {
938
+ async function api(method, path, body, options = {}) {
721
939
  const res = await fetch(`/api/v1${path}`, {
722
940
  method,
941
+ keepalive: options.keepalive === true,
723
942
  headers: {
724
943
  Authorization: `Bearer ${state.token}`,
725
944
  ...(body ? { 'Content-Type': 'application/json' } : {}),
@@ -5169,6 +5388,9 @@ function connectEvents() {
5169
5388
  // A changelog.updated sent while the socket was down is lost; catch up the open panel.
5170
5389
  if ($('changelog').open) loadChangelog();
5171
5390
  if (dockShows('github')) loadGitHub();
5391
+ catchUpNotes(msg.notesRevision, msg.notesUnreadable === true);
5392
+ } else if (msg.type === 'notes.updated') {
5393
+ applyServerNotes(msg.notes);
5172
5394
  } else if (msg.type === 'remote-access.updated') {
5173
5395
  state.remoteAccessUI?.updated();
5174
5396
  } else if (msg.type === 'news.updated') {
@@ -5457,7 +5679,7 @@ document.addEventListener('keydown', (e) => {
5457
5679
  addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
5458
5680
  addEventListener('resize', () => { if (tipFor) hideTip(); });
5459
5681
  $('notes-open').addEventListener('click', firstClick(toggleNotes));
5460
- $('notes-text').addEventListener('input', saveNotes);
5682
+ $('notes-text').addEventListener('input', () => { saveNotes(); void scheduleNotesPush(); });
5461
5683
  addEventListener('storage', notesStored);
5462
5684
  // On load, and again for a page back from the back/forward cache, which may have missed another tab's notes.
5463
5685
  addEventListener('pageshow', refreshNotes);
@@ -5474,12 +5696,16 @@ $('news').addEventListener('close', () => {
5474
5696
  newsView.opener = null;
5475
5697
  });
5476
5698
  document.addEventListener('visibilitychange', () => {
5477
- if (document.visibilityState !== 'visible') stopDictation();
5699
+ if (document.visibilityState !== 'visible') {
5700
+ stopDictation();
5701
+ flushNotes();
5702
+ }
5478
5703
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
5479
5704
  if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
5480
5705
  else scheduleRuns();
5481
5706
  });
5482
5707
  addEventListener('pagehide', () => {
5708
+ flushNotes();
5483
5709
  state.pageAway = true;
5484
5710
  activityFavicon.setPaused(true);
5485
5711
  terminalCopy.close();
package/web/index.html CHANGED
@@ -258,7 +258,7 @@
258
258
  </div>
259
259
  </section>
260
260
  <section id="notes" class="dock-panel notes-panel" role="tabpanel" aria-labelledby="notes-title" hidden>
261
- <p id="notes-sub" class="dock-sub sub" aria-live="polite">Saved in this browser as you type</p>
261
+ <p id="notes-sub" class="dock-sub sub" aria-live="polite">Saved and synced across your browsers</p>
262
262
  <div class="dock-body">
263
263
  <textarea id="notes-text" class="notes-text" aria-labelledby="notes-title" aria-describedby="notes-sub" autocomplete="off" placeholder="Prompts, commands, to-dos: anything you want to keep at hand"></textarea>
264
264
  </div>
package/web/styles.css CHANGED
@@ -743,6 +743,8 @@ a.news-link:visited { color: var(--muted); }
743
743
  }
744
744
  .notes-text:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
745
745
  .notes-panel .sub.warn { color: var(--warn); }
746
+ /* Room below the status line for the field's focus ring, and for a warning that wraps to two lines. */
747
+ .notes-panel .dock-sub { flex-shrink: 0; padding-bottom: 8px; line-height: 1.4; }
746
748
  @media (pointer: coarse) {
747
749
  /* iOS zooms in on a field with smaller text when it takes focus. */
748
750
  .notes-text { font-size: 16px; }