@volter/supercode-teams 0.3.159 → 0.3.161

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.
@@ -1117,10 +1117,10 @@ export class TeamsConnector {
1117
1117
  let started=null;
1118
1118
  try{
1119
1119
  const seen=this.origins[key]?.capture_stamps?.[options.ruleId];
1120
- // an unchanged stamp satisfies the messages, not a native bundle still owed and ready to be taken
1120
+ // An unchanged stamp cannot satisfy a held full capture or a native bundle ready to be taken.
1121
1121
  const artifact=(()=>{try{return statSync(realpathSync(options.locator.storage.path));}catch{return null;}})();
1122
1122
  const nativeOwed=Boolean(this.origins[key]?.native_deferred&&artifact&&this.nativeReady(key,artifact));
1123
- if(options.live&&!nativeOwed&&seen?.rule_revision===options.ruleRevision&&seen.stamp===this.sessionStamp(options))continue;
1123
+ if(options.live&&!nativeOwed&&!this.redactionHolds.needed(key)&&seen?.rule_revision===options.ruleRevision&&seen.stamp===this.sessionStamp(options))continue;
1124
1124
  const artifactBefore=statSync(realpathSync(options.locator.storage.path));
1125
1125
  // A session is loaded again no sooner than ten times its last load took (a 0.5 s load: every 5 s; a load
1126
1126
  // that takes minutes: every LARGE_CAPTURE_EVERY_MS), so a live session streams while the lanes stay light.
package/live-sessions.mjs CHANGED
@@ -478,11 +478,20 @@ export class LiveSessions extends EventEmitter {
478
478
  return this.paneRoots.find((root) => root.pid === entry.pid)?.pane ?? entry.tmux ?? null;
479
479
  }
480
480
 
481
+ /** Delivery never uses the registry's tmux label: a headless process can
482
+ * inherit another session's label. Only this daemon's pane/process reading
483
+ * binds input to the addressed native conversation. */
484
+ #claudeDeliveryPane(entry) {
485
+ return this.paneRoots.find((root) => root.activity?.harness === 'claude-code'
486
+ && root.activity.session_id === entry.sessionId)?.pane ?? null;
487
+ }
488
+
481
489
  /** The daemon's live panes and their root processes (`[{ pane, pid }]`), as the records read them: the panes of the
482
490
  * sessions whose own records name none (Grok's; Claude's on Windows). */
483
- panes(roots) {
484
- const placed = () => JSON.stringify([...[...this.grok].map(([id, entry]) => [id, entry.pane]), ...[...this.claude.values()].map((entry) => [entry.sessionId, this.#claudePane(entry)])]);
491
+ panes(roots, { current = true } = {}) {
492
+ const placed = () => JSON.stringify([this.panesCurrent === true, ...[...this.grok].map(([id, entry]) => [id, entry.pane]), ...[...this.claude.values()].map((entry) => [entry.sessionId, this.#claudePane(entry), this.#claudeDeliveryPane(entry)])]);
485
493
  const before = placed();
494
+ this.panesCurrent = current;
486
495
  this.paneRoots = (roots ?? []).filter((root) => root?.pane && Number.isInteger(root.pid));
487
496
  for (const entry of this.grok.values()) this.#grokPane(entry);
488
497
  if (placed() !== before) this.#changed();
@@ -656,7 +665,7 @@ export class LiveSessions extends EventEmitter {
656
665
  if (entry.name.startsWith(RELAY_PREFIX) || !alive(entry.pid)) continue;
657
666
  const address = this.#address('claude-code', entry.sessionId);
658
667
  if (seen.has(address)) continue; seen.add(address);
659
- out.push({ address, name: `${entry.name}@${this.machine}`, status: entry.status, door: 'native', pid: entry.pid, cwd: entry.cwd, pane: this.#claudePane(entry), tools: this.hasTools(entry.pid), socket: entry.socket ?? null });
668
+ out.push({ address, name: `${entry.name}@${this.machine}`, status: entry.status, door: 'native', pid: entry.pid, cwd: entry.cwd, pane: this.#claudePane(entry), delivery_pane: this.#claudeDeliveryPane(entry), panes_current: this.panesCurrent === true, tools: this.hasTools(entry.pid), socket: entry.socket ?? null });
660
669
  }
661
670
  const codex = new Set([...this.codexRunning.keys(), ...this.codexPaneOf.keys()]);
662
671
  for (const sessionId of codex) {
@@ -5,8 +5,7 @@
5
5
  // its mail folder's file events); a subscriber gets a snapshot and then every change, and holds the latest of each
6
6
  // machine it follows. A held record says how current it is: its producer's time, its stream's state and why it is not
7
7
  // open. A machine with no record is said as unread, with why; nothing reads a stale or missing record as absent.
8
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
9
- import { dirname } from 'node:path';
8
+ import { RecordState } from './record-state.mjs';
10
9
 
11
10
  /** How many of each mailbox's newest messages a record carries beside its unread mail: enough for `show` of what a
12
11
  * session was just sent or just read, small enough that a busy machine's record stays a few hundred kilobytes. */
@@ -31,20 +30,25 @@ const TOO_OLD_RETRY_MS = 60_000;
31
30
  * (a mailbox's address). `touch(section, detail)` is an event; changed sections reach every subscriber as one delta.
32
31
  */
33
32
  export class RecordProducer {
34
- constructor({ machine, readers, log = () => {} }) {
33
+ constructor({ machine, readers, store, log = () => {} }) {
35
34
  this.machine = machine; this.readers = readers; this.log = log;
36
- this.record = { machine, seq: 0, at: null, sessions: null, panes: null, capabilities: null, mail: {} };
37
- this.unread = {}; // section → why its last read failed (said in the record, never an empty section)
35
+ this.state = new RecordState(store, 'producer'); this.changes = store.changes;
36
+ this.record = this.state.load(machine)?.record ?? { machine, seq: 0, at: null, sessions: null, panes: null, capabilities: null, mail: {} };
37
+ this.unread = { ...this.record.unread };
38
+ if (this.record.seq) for (const section of SECTIONS) this.unread[section] = 'held from the previous daemon; awaiting this source\'s current snapshot';
39
+ this.record.unread = { ...this.unread };
38
40
  this.subscribers = new Map(); this.due = new Map(); this.timer = null; this.reading = null; this.lastRead = 0; // write → its granted sections (null: all)
39
41
  this.retries = new Map(); // section → { timer, backoff }: a failed read holds its retry until a read passes
40
42
  }
41
43
  /** Every section read once (the record's first snapshot), and the stream's heartbeat started. */
42
44
  start() {
43
45
  for (const section of SECTIONS) this.touch(section);
44
- this.beat = setInterval(() => { const frame = JSON.stringify({ type: 'beat', machine: this.machine, seq: this.record.seq, at: Date.now() }) + '\n'; for (const write of this.subscribers.keys()) write(frame); }, BEAT_MS);
46
+ this.beat = setInterval(() => {
47
+ for (const subscriber of this.subscribers.values()) if (subscriber.expected === this.record.seq) subscriber.send({ type: 'beat', machine: this.machine, seq: subscriber.expected, cursor: subscriber.cursor, at: Date.now() });
48
+ }, BEAT_MS);
45
49
  this.beat.unref?.();
46
50
  }
47
- stop() { this.stopped = true; clearInterval(this.beat); clearTimeout(this.timer); for (const { timer } of this.retries.values()) clearTimeout(timer); this.retries.clear(); this.subscribers.clear(); }
51
+ stop() { this.stopped = true; clearInterval(this.beat); clearTimeout(this.timer); for (const { timer } of this.retries.values()) clearTimeout(timer); this.retries.clear(); for (const subscriber of this.subscribers.values()) subscriber.end(); }
48
52
  /** A section's read failed: it is read again after a backoff (2 s doubling to 5 min) until a read passes. */
49
53
  retry(section, detail) {
50
54
  if (this.stopped) return;
@@ -82,8 +86,10 @@ export class RecordProducer {
82
86
  // a read in progress takes what is due when it ends (its finally schedules the next settle)
83
87
  if (this.reading) return;
84
88
  const due = this.due; this.due = new Map();
89
+ let commitFailed = false;
85
90
  this.reading = (async () => {
86
91
  const changed = {};
92
+ const record = { ...this.record };
87
93
  if (this.noted) { this.noted = false; changed.unread = { ...this.unread }; }
88
94
  for (const [section, details] of due) {
89
95
  const reader = this.readers[section];
@@ -91,15 +97,17 @@ export class RecordProducer {
91
97
  try {
92
98
  if (section === 'mail' && !details.has('*')) {
93
99
  const boxes = await reader([...details]);
94
- const mail = { ...this.record.mail };
100
+ if (this.stopped) return;
101
+ const mail = { ...record.mail };
95
102
  for (const address of details) { if (boxes[address]) mail[address] = boxes[address]; else delete mail[address]; }
96
- if (JSON.stringify(mail) !== JSON.stringify(this.record.mail)) { this.record.mail = mail; changed.mail = Object.fromEntries([...details].map((address) => [address, mail[address] ?? null])); }
103
+ if (JSON.stringify(mail) !== JSON.stringify(record.mail)) { record.mail = mail; changed.mail = Object.fromEntries([...details].map((address) => [address, mail[address] ?? null])); }
97
104
  } else {
98
105
  const value = await reader(null);
99
- if (JSON.stringify(value) !== JSON.stringify(this.record[section])) {
106
+ if (this.stopped) return;
107
+ if (JSON.stringify(value) !== JSON.stringify(record[section])) {
100
108
  // the sessions go as rows changed and gone (by address), not the whole list each turn
101
- changed[section] = section === 'mail' ? { '*': value } : section === 'sessions' && Array.isArray(this.record.sessions) && Array.isArray(value) ? rowDelta(this.record.sessions, value) : value;
102
- this.record[section] = value;
109
+ changed[section] = section === 'mail' ? { '*': value } : section === 'sessions' && Array.isArray(record.sessions) && Array.isArray(value) ? rowDelta(record.sessions, value) : value;
110
+ record[section] = value;
103
111
  }
104
112
  }
105
113
  if (this.unread[section]) { delete this.unread[section]; changed.unread = { ...this.unread }; }
@@ -111,21 +119,63 @@ export class RecordProducer {
111
119
  this.retry(section, details.has('*') ? null : [...details]);
112
120
  }
113
121
  }
114
- if (!Object.keys(changed).length) return;
115
- this.record.seq += 1; this.record.at = Date.now(); this.record.unread = { ...this.unread };
116
- // every follower gets every delta (its sequence continues), each with only the sections it is granted
117
- for (const [write, allowed] of this.subscribers) write(JSON.stringify({ type: 'delta', machine: this.machine, seq: this.record.seq, at: this.record.at, sections: granted(changed, allowed) }) + '\n');
122
+ if (this.stopped || !Object.keys(changed).length) return;
123
+ record.seq += 1; record.at = Date.now(); record.unread = { ...this.unread };
124
+ record.cursor = this.state.save(record, changed, { publish: true });
125
+ this.record = record;
118
126
  })();
119
127
  // reads are spaced from the end of the last one: a read longer than the interval is never followed at once
120
- try { await this.reading; } finally { this.reading = null; this.lastRead = Date.now(); if (this.due.size || this.noted) this.schedule(); }
128
+ try { await this.reading; }
129
+ catch (error) {
130
+ if (!this.stopped) {
131
+ commitFailed = true;
132
+ this.log(`machine record: its changed rows could not be committed: ${error.message}`);
133
+ for (const [section, details] of due) this.retry(section, details.has('*') ? null : [...details]);
134
+ if (!due.size) this.retry('mail', null);
135
+ this.noted = true;
136
+ }
137
+ } finally { this.reading = null; this.lastRead = Date.now(); if (!commitFailed && (this.due.size || this.noted)) this.schedule(); }
121
138
  }
122
139
  /** A subscriber: its snapshot now, then each change. Answers its unsubscribe. */
123
140
  /** A subscriber: its snapshot now, then each change, with only the sections `allowed` (null: all; a section not
124
141
  * granted is left out, said in its `unread`). Answers its unsubscribe. */
125
- subscribe(write, allowed = null) {
126
- write(JSON.stringify({ type: 'snapshot', record: grantedRecord(this.record, allowed) }) + '\n');
127
- this.subscribers.set(write, allowed);
128
- return () => this.subscribers.delete(write);
142
+ subscribe(write, allowed = null, { after = null, sequence = null, sections = null } = {}) {
143
+ const scope = allowed ? [...allowed].sort() : null;
144
+ const subscriber = { cursor: after, expected: sequence, closed: false };
145
+ subscriber.end = () => { subscriber.closed = true; subscriber.stop?.(); this.subscribers.delete(write); };
146
+ subscriber.send = (frame) => {
147
+ if (subscriber.closed) return;
148
+ try { write(JSON.stringify(frame) + '\n'); } catch { subscriber.end(); }
149
+ };
150
+ this.subscribers.set(write, subscriber);
151
+ const resumable = Number.isSafeInteger(sequence) && sequence >= 0 && JSON.stringify(scope) === JSON.stringify(sections);
152
+ subscriber.stop = this.changes.follow((page) => {
153
+ if (subscriber.closed || this.stopped) return;
154
+ if (page.error) { subscriber.end(); return; }
155
+ const snapshot = () => {
156
+ const cursor = this.changes.cursor();
157
+ subscriber.resumed = true;
158
+ subscriber.expected = this.record.seq; subscriber.cursor = cursor;
159
+ subscriber.send({ type: 'snapshot', record: { ...grantedRecord(this.record, allowed), cursor, sections_granted: scope } });
160
+ return cursor;
161
+ };
162
+ if (page.reset || subscriber.expected == null) return snapshot();
163
+ if (!subscriber.resumed) {
164
+ subscriber.resumed = true;
165
+ subscriber.send({ type: 'resume', machine: this.machine, seq: subscriber.expected, cursor: after, sections_granted: scope });
166
+ }
167
+ for (const change of page.changes) {
168
+ if (change.key.machine !== this.machine) continue;
169
+ if (change.value?.seq !== subscriber.expected + 1) return snapshot();
170
+ subscriber.expected = change.value.seq;
171
+ subscriber.cursor = { id: page.cursor.id, sequence: change.sequence };
172
+ subscriber.send({ type: 'delta', machine: this.machine, seq: subscriber.expected, cursor: subscriber.cursor, at: change.value.at, sections: granted(change.value.sections, allowed) });
173
+ }
174
+ if (!page.more && subscriber.expected !== this.record.seq) return snapshot();
175
+ subscriber.cursor = page.cursor;
176
+ subscriber.send({ type: 'cursor', machine: this.machine, seq: subscriber.expected, cursor: page.cursor, caught_up: !page.more });
177
+ }, { after: resumable ? after : null, topics: ['machine-record'], initial: true });
178
+ return subscriber.end;
129
179
  }
130
180
  }
131
181
 
@@ -179,28 +229,24 @@ function notGranted(error) {
179
229
  * said so, its last record kept as stale.
180
230
  */
181
231
  export class RecordHolder {
182
- constructor({ self, open, file, log = () => {} }) {
232
+ constructor({ self, open, file, store, log = () => {} }) {
183
233
  this.self = self; this.open = open; this.file = file; this.log = log;
234
+ this.state = new RecordState(store, 'holder');
184
235
  this.held = new Map(); // machine → { record, state: open|opening|closed|too-old|unlinked, since, why, received_at, stream, retry, backoff }
185
236
  this.load();
186
237
  }
187
238
  load() {
188
- let saved = {};
189
- try { saved = JSON.parse(readFileSync(this.file, 'utf8')).machines ?? {}; } catch { /* none kept yet */ }
239
+ this.state.importLegacy(this.file);
190
240
  // a record kept from before this daemon started is stale until its machine's stream gives it again
191
- for (const [machine, kept] of Object.entries(saved)) this.held.set(machine, { record: kept.record ?? null, state: 'closed', since: kept.received_at ?? null, why: 'held from before this daemon started; not read since', received_at: kept.received_at ?? null });
241
+ for (const [machine, kept] of this.state.all()) this.held.set(machine, { record: kept.record, state: 'closed', since: kept.meta?.received_at ?? null, why: 'held from before this daemon started; not read since', received_at: kept.meta?.received_at ?? null });
192
242
  }
193
- save() {
194
- if (this.saving) return; this.saving = true;
195
- setTimeout(() => {
196
- this.saving = false;
197
- try {
198
- mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
199
- const machines = Object.fromEntries([...this.held].map(([machine, held]) => [machine, { record: held.record, received_at: held.received_at }]));
200
- const temp = `${this.file}.${process.pid}.tmp`;
201
- writeFileSync(temp, JSON.stringify({ version: 1, machines }) + '\n', { mode: 0o600 }); renameSync(temp, this.file);
202
- } catch (error) { this.log(`machine records could not be kept: ${error.message}`); }
203
- }, 500).unref?.();
243
+ save(machine, changed = {}) {
244
+ if (this.closed) return;
245
+ const held = this.held.get(machine);
246
+ const meta = { received_at: held?.received_at ?? null, stream: held?.state ?? 'closed', since: held?.since ?? null, why: held?.why ?? null };
247
+ if (held?.record) this.state.save(held.record, changed, { meta });
248
+ else if (held) this.state.saveEmpty(machine, meta);
249
+ else this.state.remove(machine);
204
250
  }
205
251
  /** The linked machines of this machine's team, from the presence stream (its own events): followed or let go. */
206
252
  follow(items) {
@@ -208,9 +254,7 @@ export class RecordHolder {
208
254
  const linked = new Set((items ?? []).filter((item) => item.linked).map((item) => normal(item.name)).filter((name) => name && name !== this.self));
209
255
  // a machine no longer in the team is forgotten (its record too); one in it but not linked is offline: unlinked,
210
256
  // its last record kept as offline (readers leave it out, as they leave out any machine that is not online)
211
- let forgot = false;
212
- for (const machine of [...this.held.keys()]) if (!team.has(machine)) { this.let(machine, 'no longer in the team'); this.held.delete(machine); forgot = true; }
213
- if (forgot) this.save();
257
+ for (const machine of [...this.held.keys()]) if (!team.has(machine)) { this.let(machine, 'no longer in the team'); this.held.delete(machine); this.state.remove(machine); }
214
258
  for (const [machine, held] of this.held) if (!linked.has(machine) && held.state !== 'unlinked') this.let(machine, 'offline: not linked to the team\'s server');
215
259
  for (const machine of linked) { const held = this.held.get(machine); if (!held || held.state === 'unlinked' || ((held.state === 'closed' || held.state === 'too-old') && !held.retry)) this.subscribe(machine); }
216
260
  }
@@ -219,12 +263,14 @@ export class RecordHolder {
219
263
  clearTimeout(held.retry); held.retry = null; clearTimeout(held.quiet); held.attempt = null;
220
264
  try { held.stream?.close?.(); held.stream?.destroy?.(); } catch { /* closed */ }
221
265
  Object.assign(held, { stream: null, state: 'unlinked', since: Date.now(), why });
266
+ this.save(machine);
222
267
  }
223
268
  subscribe(machine) {
224
269
  const held = this.held.get(machine) ?? { record: null, received_at: null, backoff: 0 };
225
270
  this.held.set(machine, held);
226
271
  clearTimeout(held.retry); held.retry = null; clearTimeout(held.quiet);
227
272
  Object.assign(held, { state: 'opening', why: null });
273
+ this.save(machine);
228
274
  let buffer = '';
229
275
  const attempt = {}; held.attempt = attempt;
230
276
  const closed = (why, state = 'closed') => {
@@ -234,6 +280,7 @@ export class RecordHolder {
234
280
  const stream = held.stream;
235
281
  held.attempt = null;
236
282
  Object.assign(held, { stream: null, state, since: Date.now(), why: String(why ?? 'the stream closed').slice(0, 300) });
283
+ this.save(machine);
237
284
  try { stream?.close?.(); } catch { /* closing */ }
238
285
  // opened again after a backoff, 2 s doubling to 5 min: an obligation of this machine, never a sweep. A daemon too
239
286
  // old to serve records is asked again every TOO_OLD_RETRY_MS: only its update answers it (asked every 8-16 s, it
@@ -251,6 +298,7 @@ export class RecordHolder {
251
298
  // the opener attaches these at once, before the stream can deliver anything: the snapshot and each delta after it
252
299
  // are applied in order, none lost to a listener attached late
253
300
  const sink = {
301
+ resume: held.record?.cursor ? { after: held.record.cursor, sequence: held.record.seq, sections: held.record.sections_granted ?? null } : {},
254
302
  data: (chunk) => {
255
303
  if (held.attempt !== attempt) return;
256
304
  heard();
@@ -260,7 +308,9 @@ export class RecordHolder {
260
308
  const line = buffer.slice(0, at); buffer = buffer.slice(at + 1);
261
309
  if (!line) continue;
262
310
  let frame; try { frame = JSON.parse(line); } catch { continue; }
263
- const gap = this.apply(machine, held, frame);
311
+ let gap;
312
+ try { gap = this.apply(machine, held, frame); }
313
+ catch (error) { closed(`its record rows could not be applied: ${error.message}`); return; }
264
314
  if (gap) { closed(gap); return; }
265
315
  }
266
316
  },
@@ -273,7 +323,7 @@ export class RecordHolder {
273
323
  }, (error) => {
274
324
  // refused for want of any grant (the machine's or its records door's): what was read under the grant is not
275
325
  // kept, here or in the saved file; a grant given again reads it afresh
276
- if (held.attempt === attempt && notGranted(error) && held.record) { held.record = null; held.received_at = null; this.save(); }
326
+ if (held.attempt === attempt && notGranted(error) && held.record) { held.record = null; held.received_at = null; this.save(machine); }
277
327
  // a daemon from before machine records (supercode v0.5.200) has no such door: too old to read, not a stream
278
328
  // that closed; asked again on the same backoff, so an update is read once it runs
279
329
  const message = String(error?.message ?? error);
@@ -282,19 +332,35 @@ export class RecordHolder {
282
332
  });
283
333
  }
284
334
  apply(machine, held, frame) {
335
+ let changed = {};
285
336
  if (frame.type === 'snapshot' && frame.record) {
337
+ if (frame.record.machine !== machine) return `the snapshot names ${frame.record.machine}, not ${machine}`;
286
338
  held.record = frame.record;
339
+ changed = { sessions: frame.record.sessions, mail: { '*': frame.record.mail ?? {} }, panes: frame.record.panes, capabilities: frame.record.capabilities, unread: frame.record.unread ?? {} };
340
+ } else if (frame.type === 'resume') {
341
+ if (!held.record || frame.seq !== held.record.seq || frame.cursor?.id !== held.record.cursor?.id || JSON.stringify(frame.sections_granted) !== JSON.stringify(held.record.sections_granted)) return 'the resumed record does not match the held snapshot or its grants';
342
+ Object.assign(held, { state: 'catching-up', why: 'resuming its retained row changes' });
343
+ this.save(machine);
344
+ return null;
345
+ } else if (frame.type === 'cursor') {
346
+ if (!held.record || frame.seq !== held.record.seq || frame.cursor?.id !== held.record.cursor?.id) return 'the record cursor does not match its applied sequence';
347
+ held.record.cursor = frame.cursor;
348
+ Object.assign(held, { state: frame.caught_up ? 'open' : 'catching-up', why: frame.caught_up ? null : 'resuming its retained row changes', received_at: Date.now(), backoff: 0 });
349
+ this.save(machine);
350
+ return null;
287
351
  } else if (frame.type === 'beat') {
288
352
  // a beat at the held sequence keeps the record fresh; one past it is a delta this stream lost: read anew
289
353
  if (held.state !== 'open' || frame.seq !== held.record?.seq) return `a beat at sequence ${frame.seq} does not match the held record (${held.state === 'open' ? held.record?.seq : 'no snapshot yet'}): read again from a snapshot`;
290
354
  // a beat changes nothing kept: only how recently the machine answered
291
355
  held.received_at = Date.now(); held.backoff = 0;
356
+ if (frame.cursor?.id === held.record.cursor?.id) { held.record.cursor = frame.cursor; this.save(machine); }
292
357
  return null;
293
358
  } else if (frame.type === 'delta') {
294
359
  // a delta that does not follow the held sequence (or comes before this stream's snapshot) is a gap: the stream is
295
360
  // read again from a snapshot
296
- if (held.state !== 'open' || frame.seq !== held.record?.seq + 1) return `a delta at sequence ${frame.seq} does not follow the held record (${held.state === 'open' ? held.record?.seq : 'no snapshot yet'}): read again from a snapshot`;
297
- const record = { ...held.record, seq: frame.seq, at: frame.at };
361
+ if (!['open', 'catching-up'].includes(held.state) || frame.seq !== held.record?.seq + 1) return `a delta at sequence ${frame.seq} does not follow the held record (${held.record?.seq ?? 'no snapshot yet'}): read again from a snapshot`;
362
+ const record = { ...held.record, seq: frame.seq, at: frame.at, ...(frame.cursor ? { cursor: frame.cursor } : {}) };
363
+ if (frame.cursor && held.record.cursor?.id !== frame.cursor.id) return 'the delta names a different record database';
298
364
  for (const [section, value] of Object.entries(frame.sections ?? {})) {
299
365
  if (section === 'unread') record.unread = value;
300
366
  else if (section === 'sessions' && value?.delta) {
@@ -310,9 +376,11 @@ export class RecordHolder {
310
376
  } else record[section] = value;
311
377
  }
312
378
  held.record = record;
379
+ changed = frame.sections ?? {};
313
380
  } else return null;
314
- Object.assign(held, { state: 'open', since: held.state === 'open' ? held.since : Date.now(), why: null, received_at: Date.now(), backoff: 0, saidTooOld: false });
315
- this.save();
381
+ const catchingUp = held.state === 'catching-up' && frame.type !== 'snapshot';
382
+ Object.assign(held, { state: catchingUp ? 'catching-up' : 'open', since: held.state === 'open' ? held.since : Date.now(), why: catchingUp ? 'resuming its retained row changes' : null, received_at: Date.now(), backoff: 0, saidTooOld: false });
383
+ this.save(machine, changed);
316
384
  return null;
317
385
  }
318
386
  /** Every followed machine's latest record and how current it is. */