@volter/supercode-teams 0.2.23 → 0.2.25

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/bin/workspace.mjs CHANGED
@@ -865,4 +865,9 @@ Use --help with a command for its required arguments and options.
865
865
  `);
866
866
  }
867
867
  export function errorExit(error) { return error.exitCode ?? ERROR_EXITS[error.code] ?? 1; }
868
- if (import.meta.url === new URL(`file://${process.argv[1]}`).href) workspaceMain().catch(error => { process.stderr.write(`${JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } })}\n`); process.exit(errorExit(error)); });
868
+ if (import.meta.url === new URL(`file://${process.argv[1]}`).href) workspaceMain().catch(error => { process.stderr.write(`${JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } })}\n`); process.exitCode = errorExit(error);
869
+ // Drain rather than process.exit(): on Windows, exiting while a just-used fetch connection is still closing aborts
870
+ // Node inside libuv (src\win\async.c, `!(handle->flags & UV_HANDLE_CLOSING)`) and loses the exit code. Whatever
871
+ // still holds the event loop open gets a moment, then the exit is forced.
872
+ setTimeout(() => process.exit(process.exitCode), 2_000).unref();
873
+ });
@@ -36,6 +36,12 @@ export interface TeamsConnectorOptions {
36
36
  effectExecutor?: (effect: NativeEffect, context: {core: ConnectorCore; signal: AbortSignal}) => Promise<{native_session_id?: string | null; runtime_id?: string | null} | void>;
37
37
  clock?: ConnectorClock;
38
38
  fetch?: typeof globalThis.fetch;
39
+ /** Milliseconds between heartbeats, sent on their own timer independent of the capture pass (default 15000). */
40
+ heartbeatMs?: number;
41
+ /** A native file at least nativeIdleBytes long gets its native bundle only after this long without a write (default 60000). */
42
+ nativeIdleMs?: number;
43
+ /** Size from which a native file's bundle waits for it to be idle (default 8 MiB). */
44
+ nativeIdleBytes?: number;
39
45
  }
40
46
 
41
47
  export interface CaptureOptions {
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, realpathSync, statSync, openSync, closeSync, fsyncSync, unlinkSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, realpathSync, statSync, openSync, closeSync, fsyncSync, unlinkSync, readSync, readdirSync } from 'node:fs';
2
2
  import { basename, dirname, join } from 'node:path';
3
3
  import { randomBytes, createHash } from 'node:crypto';
4
4
  import { canonicalJson } from '../contracts/index.mjs';
@@ -6,6 +6,19 @@ import { EffectConsumer } from './effects.mjs';
6
6
  import { NativeEffects } from './native-effects.mjs';
7
7
 
8
8
  const SYNC_PROBLEM_LIMIT=20;
9
+ // Live follows are bounded. The harness SDK lets at most 64 subscription opens await replies at once (sdk/typescript
10
+ // core.mjs MAX_PENDING_RUNTIME_OPENS; more reject with SUBSCRIPTION_OPEN_CAPACITY) and every live follow is a follower
11
+ // the service polls, while a subtree rule can cover hundreds of sessions. Only the most recently active sessions (at
12
+ // most FOLLOW_LIMIT, half the SDK's limit for headroom, active within FOLLOW_ACTIVE_WINDOW_MS) are followed live; the
13
+ // rest are covered by each beat's stamp-based capture. Followers rotate as activity changes, with hysteresis: a
14
+ // follower is kept while it stays in the window and ranks within FOLLOW_KEEP_RANK, so sessions near the cutoff do not
15
+ // stop and reopen (each reopen a full load and capture) on alternate beats; new follows only fill up to FOLLOW_LIMIT.
16
+ const FOLLOW_LIMIT=32,FOLLOW_KEEP_RANK=40,FOLLOW_ACTIVE_WINDOW_MS=30*60*1000,FOLLOW_RETRY_MS=60*1000;
17
+ const CAPACITY_CODE='SUBSCRIPTION_OPEN_CAPACITY';
18
+ const capacityError=error=>error?.code===CAPACITY_CODE||/Too many unresolved subscriptions/.test(String(error?.message??''));
19
+ // An error of the connection itself (the outbox, authentication, the server), not of one session.
20
+ const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
21
+ const sameError=(a,b)=>a!=null&&b!=null&&a.code===b.code&&a.message===b.message;
9
22
  const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='string'?error.code:null,message:String(error.message??error).slice(0,500),at};
10
23
  /** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
11
24
  export function readSyncStatus(stateDir){
@@ -39,6 +52,15 @@ function settledToolTail(previous,messages){
39
52
  return messages.slice(0,count).map(digestOf);
40
53
  }
41
54
  const hash=value=>createHash('sha256').update(value).digest('hex');
55
+ const CHUNK_BYTES=1024*1024;
56
+ // A native file at least NATIVE_IDLE_BYTES long gets its native bundle only once it has gone NATIVE_IDLE_MS
57
+ // without a write. Translating reads the whole file, takes minutes for a 100 MB transcript, and holds the
58
+ // harness server's only request reader while it runs; a live Claude Code transcript is appended to on every
59
+ // message and tool result, so translating it mid-turn is wasted (the file has moved by the time it finishes).
60
+ // A minute is four beats with no write: the turn is over, or waiting on the user or one long tool call.
61
+ // Smaller files translate in well under a second and keep getting a bundle with every capture.
62
+ const NATIVE_IDLE_MS=60000,NATIVE_IDLE_BYTES=8*1024*1024;
63
+ const fileStamp=stat=>`${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
42
64
  const recordedAt=entry=>{if(Number.isSafeInteger(entry.recorded_at))return entry.recorded_at;if(Number.isSafeInteger(entry.timestamp))return entry.timestamp;const value=entry.metadata?.timestamp??entry.value?.metadata?.timestamp??entry.timestamp;if(typeof value!=='string')return null;const parsed=Date.parse(value);return Number.isSafeInteger(parsed)?parsed:null;};
43
65
  const serverOrigin=value=>{const url=new URL(value),loopback=url.hostname==='localhost'||url.hostname==='::1'||url.hostname==='[::1]'||/^127(?:\.\d{1,3}){3}$/.test(url.hostname);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||(url.pathname!==''&&url.pathname!=='/')||(url.protocol==='http:'&&!loopback))throw new TypeError('server must be an HTTPS origin (HTTP is loopback-only)');return url.origin;};
44
66
  function atomic(path, value) {
@@ -57,19 +79,24 @@ function atomic(path, value) {
57
79
  }
58
80
  }
59
81
  export class TeamsConnector {
60
- constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
82
+ constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch,heartbeatMs=15000,nativeIdleMs=NATIVE_IDLE_MS,nativeIdleBytes=NATIVE_IDLE_BYTES}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl,heartbeatMs,nativeIdleMs,nativeIdleBytes});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};if(Array.isArray(this.state.sync_status?.problems))this.state.sync_status.problems=this.state.sync_status.problems.filter(item=>item?.code!==CAPACITY_CODE);this.state.native_cache??={};this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
61
83
  save(){atomic(this.statePath,this.state);}
62
84
  // What `supercode teams status` shows about this connector: its last error, and the sessions it could not capture
63
85
  // (most recent first, bounded; an entry clears when that session next captures). They are kept in connector.json,
64
86
  // so another process reads them while this one runs; the error itself stays in memory as lastError.
65
87
  get lastError(){return this.lastErrorValue;}
66
- set lastError(error){this.lastErrorValue=error;this.syncStatus().last_error=errorSummary(error,this.clock.nowMs());this.saveQuietly();}
88
+ // The same error (code and message) again changes nothing on disk, so it is not saved again: an error that repeats
89
+ // every beat does not rewrite connector.json every beat. Its time stays that of its first occurrence in the run.
90
+ set lastError(error){this.lastErrorValue=error;const status=this.syncStatus(),summary=errorSummary(error,this.clock.nowMs());if(sameError(status.last_error,summary))return;status.last_error=summary;this.saveQuietly();}
67
91
  syncStatus(){const status=this.state.sync_status??={problems:[],last_error:null};status.problems??=[];return status;}
68
92
  saveQuietly(){try{this.save();}catch{}}
69
93
  noteSessionProblem({harness,nativeSessionId,ruleId=null},error){
70
- const status=this.syncStatus(),at=this.clock.nowMs();
71
- status.problems=[{harness,session_id:nativeSessionId,rule_id:ruleId,...errorSummary(error,at)},...status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId))].slice(0,SYNC_PROBLEM_LIMIT);
72
- this.lastError=error;
94
+ const status=this.syncStatus(),at=this.clock.nowMs(),summary=errorSummary(error,at);
95
+ // The same session failing the same way on every beat keeps its entry where it is.
96
+ const listed=status.problems.some(item=>item.harness===harness&&item.session_id===nativeSessionId&&item.rule_id===ruleId&&sameError(item,summary));
97
+ if(!listed)status.problems=[{harness,session_id:nativeSessionId,rule_id:ruleId,...summary},...status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId))].slice(0,SYNC_PROBLEM_LIMIT);
98
+ this.lastErrorValue=error;const known=sameError(status.last_error,summary);if(!known)status.last_error=summary;
99
+ if(!listed||!known)this.saveQuietly();
73
100
  }
74
101
  clearSessionProblem(harness,nativeSessionId){
75
102
  const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
@@ -133,7 +160,7 @@ export class TeamsConnector {
133
160
  this.heartbeatTimer = setInterval(() => {
134
161
  if (this.abort.signal.aborted) return;
135
162
  void heartbeat().catch(error => { this.lastError = error; });
136
- }, 15000);
163
+ }, this.heartbeatMs);
137
164
  return this.configurationValue;
138
165
  }
139
166
  async effectFetch(method, path, {body, contentType='application/json', raw=false, freshKey=false}={}) {
@@ -158,14 +185,14 @@ export class TeamsConnector {
158
185
  pollEffects() { return this.effects.poll(); }
159
186
  async syncConfiguredSessions(configuration,roots){
160
187
  if(typeof this.core.discover!=='function')return;
161
- this.followers??=new Map();
188
+ this.followers??=new Map();this.followRetry??=new Map();this.stoppingFollowers??=new Set();
162
189
  this.nativeEffects.beginDiscovery();
163
190
  const activeRules=new Map(configuration.rules.filter(item=>item.state==='active').map(rule=>[rule.id,rule]));
164
- for(const [key,follower] of this.followers){const rule=activeRules.get(follower.ruleId);if(!rule||rule.revision!==follower.ruleRevision){follower.controller.abort();this.followers.delete(key);}}
191
+ for(const [key,follower] of this.followers){const rule=activeRules.get(follower.ruleId);if(!rule||rule.revision!==follower.ruleRevision){this.stopFollower(key);}}
165
192
  // A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
166
193
  // reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
167
194
  // machine; rules over the same root and harnesses share the scan.
168
- const discoveries=new Map();
195
+ const discoveries=new Map(),followCandidates=[],discovered=new Set();
169
196
  for(const rule of activeRules.values()){
170
197
  const root=roots.find(item=>item.id===rule.selector.root_id);
171
198
  if(!root)continue;
@@ -185,6 +212,7 @@ export class TeamsConnector {
185
212
  for(const descriptor of descriptors){
186
213
  const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
187
214
  if(!locator||!nativeSessionId||!harness)continue;
215
+ discovered.add(`${harness}:${nativeSessionId}`);
188
216
  const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:workspace,configuration};
189
217
  // A session whose native store has not changed since this rule last captured it is not reloaded.
190
218
  // One session that cannot be captured (a transcript that rewrote its history needs an explicit resync)
@@ -193,22 +221,68 @@ export class TeamsConnector {
193
221
  // the pass. The session keeps its native binding either way, so it can still be attached to.
194
222
  let captured = true;
195
223
  if (this.sessionChanged(options)) try { await this.capture(options); } catch (error) {
196
- if (error.outbox || error.status === 401 || error.status === 403 || error.status >= 500 || this.abort.signal.aborted) throw error;
224
+ if (connectionError(error) || this.abort.signal.aborted) throw error;
197
225
  captured = false; this.noteSessionProblem(options, error);
198
226
  process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
199
227
  }
200
228
  try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
201
229
  if (!captured) continue;
202
- const key=`${rule.id}:${harness}:${nativeSessionId}`;
203
- if(this.followers.has(key)||typeof this.core.session(locator).follow!=='function')continue;
204
- const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
205
- // A follow event may land between beats, after the rule changed: it reads the configuration afresh.
206
- const followed={...options,configuration:null,stamp:undefined};
207
- const task=(async()=>{for await(const _event of this.core.session(locator).follow({signal:controller.signal})){if(controller.signal.aborted)break;await this.capture(followed);}})().catch(error=>{if(!controller.signal.aborted)this.noteSessionProblem(followed,error);}).finally(()=>{this.abort.signal.removeEventListener('abort',stop);this.followers.delete(key);});
208
- this.followers.set(key,{ruleId:rule.id,ruleRevision:rule.revision,controller,task});
230
+ followCandidates.push({key:`${rule.id}:${harness}:${nativeSessionId}`,activity:this.sessionActivity(options),options});
209
231
  }
210
232
  }
211
233
  this.nativeEffects.endDiscovery();
234
+ this.pruneNativeCache(discovered);
235
+ this.rotateFollowers(followCandidates);
236
+ }
237
+ // When a session was last active: its descriptor's updated_at_ms, else its native file's modification time.
238
+ sessionActivity({locator,descriptor}){
239
+ let activity=Number.isSafeInteger(descriptor?.updated_at_ms)?descriptor.updated_at_ms:null;
240
+ const path=locator?.storage?.kind==='file'&&typeof locator.storage.path==='string'?locator.storage.path:null;
241
+ if(path)try{activity=Math.max(activity??0,statSync(path).mtimeMs);}catch{}
242
+ return activity;
243
+ }
244
+ // Follows the most recently active candidates (active within the window): a follower is kept while it ranks
245
+ // within FOLLOW_KEEP_RANK, every other one stops, and the FOLLOW_LIMIT most recent open new follows while fewer
246
+ // than FOLLOW_LIMIT are live. A capacity refusal is not a session's failure: the connector backs off for a while.
247
+ rotateFollowers(candidates){
248
+ const now=this.clock.nowMs(),seen=new Set(),ranked=[];
249
+ for(const candidate of candidates.filter(item=>item.activity!==null&&now-item.activity<=FOLLOW_ACTIVE_WINDOW_MS).sort((a,b)=>b.activity-a.activity)){
250
+ if(seen.has(candidate.key)||typeof this.core.session(candidate.options.locator).follow!=='function')continue;
251
+ seen.add(candidate.key);ranked.push(candidate);if(ranked.length>=FOLLOW_KEEP_RANK)break;
252
+ }
253
+ const wanted=ranked.slice(0,FOLLOW_LIMIT);
254
+ for(const key of this.followers.keys())if(!seen.has(key))this.stopFollower(key);
255
+ for(const [key,until] of this.followRetry)if(until<=now)this.followRetry.delete(key);
256
+ for(const {key,options} of wanted){
257
+ if(this.followers.has(key)||this.followRetry.has(key))continue;
258
+ // A stopped follower holds its slot until it has closed, so live follows never exceed the bound.
259
+ if((this.followBackoffUntil??0)>now||this.followers.size+this.stoppingFollowers.size>=FOLLOW_LIMIT)break;
260
+ this.follow(key,options);
261
+ }
262
+ }
263
+ stopFollower(key){
264
+ const follower=this.followers.get(key);if(!follower)return;
265
+ follower.controller.abort();this.followers.delete(key);
266
+ this.stoppingFollowers.add(follower.task);void follower.task.finally(()=>this.stoppingFollowers.delete(follower.task));
267
+ }
268
+ follow(key,options){
269
+ const {locator}=options,controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
270
+ // A follow event may land between beats, after the rule changed: it reads the configuration afresh.
271
+ const followed={...options,configuration:null,stamp:undefined};
272
+ const task=(async()=>{
273
+ for await(const _event of this.core.session(locator).follow({signal:controller.signal})){
274
+ if(controller.signal.aborted)break;
275
+ // A capture that fails is that session's problem, as on a beat; the follow goes on.
276
+ try{await this.capture(followed);}catch(error){if(controller.signal.aborted)break;if(connectionError(error))this.lastError=error;else this.noteSessionProblem(followed,error);}
277
+ }
278
+ })().catch(error=>{
279
+ if(controller.signal.aborted)return;
280
+ // The follow itself failed: the beat's capture still covers the session, so it is not listed as not syncing.
281
+ // A capacity refusal pauses every new follow for a while; any other failure retries this one after a while.
282
+ if(capacityError(error))this.followBackoffUntil=this.clock.nowMs()+FOLLOW_RETRY_MS;
283
+ else{this.followRetry.set(key,this.clock.nowMs()+FOLLOW_RETRY_MS);this.lastError=error;}
284
+ }).finally(()=>{this.abort.signal.removeEventListener('abort',stop);if(this.followers.get(key)?.controller===controller)this.followers.delete(key);});
285
+ this.followers.set(key,{ruleId:options.ruleId,ruleRevision:options.ruleRevision,controller,task});
212
286
  }
213
287
  // What a session's native store looked like when discovery listed it: its file's size and modification
214
288
  // time (read before any load, so a write after it reads as a change), those of the file's SQLite write-ahead
@@ -229,8 +303,77 @@ export class TeamsConnector {
229
303
  if(stamp===null||this.state.outbox.length)return true;
230
304
  const rule=options.configuration?.rules?.find(item=>item.id===options.ruleId&&item.revision===options.ruleRevision&&item.state==='active');if(!rule)return true;
231
305
  const configured=rule.backfill??options.backfill??'all',mode=typeof configured==='string'?configured:configured.mode;
232
- const seen=this.state.origins[`${options.harness}:${options.nativeSessionId}`]?.capture_stamps?.[options.ruleId];
233
- return !(seen&&seen.stamp===stamp&&seen.rule_revision===options.ruleRevision&&seen.backfill_mode===mode);
306
+ const key=`${options.harness}:${options.nativeSessionId}`,origin=this.state.origins[key],seen=origin?.capture_stamps?.[options.ruleId];
307
+ if(!(seen&&seen.stamp===stamp&&seen.rule_revision===options.ruleRevision&&seen.backfill_mode===mode))return true;
308
+ // Published without its native bundle while it was live: captured again once it has been idle long enough.
309
+ if(origin?.native_deferred&&options.locator?.storage?.kind==='file')try{return this.nativeReady(key,statSync(realpathSync(options.locator.storage.path)));}catch{return false;}
310
+ return false;
311
+ }
312
+ // Whether a capture may attach the native bundle of a file in this state: its translation is cached (and did
313
+ // not fail), or it is small, or it has been idle for nativeIdleMs.
314
+ nativeReady(key,stat){
315
+ const cached=this.state.native_cache[key];
316
+ if(cached?.stamp===fileStamp(stat))return !cached.failed;
317
+ return stat.size<this.nativeIdleBytes||this.clock.nowMs()-stat.mtimeMs>=this.nativeIdleMs;
318
+ }
319
+ // Native artifact bytes are spilled to content-addressed files under the state directory, never into
320
+ // connector.json: a 129 MB transcript was ~172 MB of base64 held in the outbox and rewritten on every save().
321
+ spillDir(){return join(this.stateDir,'artifacts');}
322
+ spill(bytes){
323
+ const sha256=hash(bytes),name=`${sha256}.bin`,path=join(this.spillDir(),name);
324
+ if(!existsSync(path)){
325
+ mkdirSync(this.spillDir(),{recursive:true,mode:0o700});
326
+ const temporary=`${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`,fd=openSync(temporary,'wx',0o600);
327
+ try{writeFileSync(fd,bytes);fsyncSync(fd);}catch(error){closeSync(fd);try{unlinkSync(temporary);}catch{}throw error;}
328
+ closeSync(fd);renameSync(temporary,path);
329
+ }
330
+ const chunk_sha256=[];for(let at=0;at<bytes.length||chunk_sha256.length===0;at+=CHUNK_BYTES)chunk_sha256.push(hash(bytes.subarray(at,Math.min(bytes.length,at+CHUNK_BYTES))));
331
+ return {spill:name,sha256,byte_length:bytes.length,chunk_sha256};
332
+ }
333
+ openSpill(name){return openSync(join(this.spillDir(),name),'r');}
334
+ // Each chunk of a spilled artifact, read back one at a time; null when the file is gone or no longer matches.
335
+ *spilledChunks(artifact){
336
+ let fd;try{fd=this.openSpill(artifact.spill.file);}catch{yield null;return;}
337
+ try{
338
+ for(let index=0;index<artifact.manifest.chunks;index++){
339
+ const length=Math.max(0,Math.min(CHUNK_BYTES,artifact.manifest.byte_length-index*CHUNK_BYTES)),bytes=Buffer.alloc(length);
340
+ const read=length?readSync(fd,bytes,0,length,index*CHUNK_BYTES):0;
341
+ if(read!==length||hash(bytes)!==artifact.spill.chunk_sha256[index]){yield null;return;}
342
+ yield {upload_id:artifact.manifest.upload_id,index,sha256:artifact.spill.chunk_sha256[index],bytes_base64:bytes.toString('base64')};
343
+ }
344
+ }finally{closeSync(fd);}
345
+ }
346
+ // Spill files no queued capture and no cached translation refers to are removed.
347
+ pruneSpill(){
348
+ const kept=new Set();
349
+ for(const item of this.state.outbox)for(const artifact of item.artifacts??[])if(artifact.spill)kept.add(artifact.spill.file);
350
+ for(const cached of Object.values(this.state.native_cache))for(const file of cached.files??[])kept.add(file.spill);
351
+ let names=[];try{names=readdirSync(this.spillDir());}catch{return;}
352
+ for(const name of names)if(!kept.has(name)&&!name.endsWith('.tmp'))try{unlinkSync(join(this.spillDir(),name));}catch{}
353
+ }
354
+ // A cached translation of a session no active rule discovers any more (deleted, moved, its rule gone) is dropped
355
+ // with its spill files, so a deleted 129 MB transcript's bundle does not stay on disk for good.
356
+ pruneNativeCache(discovered){
357
+ // A beat that discovers nothing while translations are cached (a home on a drive that is briefly offline) says
358
+ // nothing about those sessions, so it keeps them rather than re-translating every large session later.
359
+ if(!discovered.size&&Object.keys(this.state.native_cache).length)return;
360
+ const stale=Object.keys(this.state.native_cache).filter(key=>!discovered.has(key));if(!stale.length)return;
361
+ for(const key of stale)delete this.state.native_cache[key];
362
+ this.save();this.pruneSpill();
363
+ }
364
+ // A translated bundle, spilled, in the form the cache keeps: one entry per file, primary first or not.
365
+ spillTranslated(translated,defaultName){
366
+ const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
367
+ if(files.filter(file=>file.role==='primary').length!==1)throw Object.assign(new Error('native codec bundle must contain exactly one primary file'),{code:'fidelity_unavailable'});
368
+ let total=0;const spilled=[];
369
+ for(const file of files){const bytes=Buffer.from(file.content,'utf8');total+=bytes.length;if(total>256*1024*1024)throw Object.assign(new Error('native artifact bundle exceeds 256 MiB'),{code:'request_too_large'});spilled.push({path:file.path,role:file.role,...this.spill(bytes)});}
370
+ return {fidelity:translated.fidelity,residue:translated.residue??[],files:spilled};
371
+ }
372
+ // The outbox artifacts and native_bundle of one capture from a spilled bundle; every capture gets fresh upload ids.
373
+ bundleArtifacts(bundle,harness){
374
+ const artifacts=[],files=[];
375
+ for(const file of bundle.files){const uploadId=`upload_${randomBytes(16).toString('hex')}`;artifacts.push({manifest:{upload_id:uploadId,relative_name:file.path,sha256:file.sha256,byte_length:file.byte_length,chunks:file.chunk_sha256.length,media_type:'application/octet-stream',role:file.role==='source_recovery'?'sidecar':'native'},spill:{file:file.spill,chunk_sha256:file.chunk_sha256}});files.push({upload_id:uploadId,role:file.role});}
376
+ return {artifacts,nativeBundle:{id:files.find(file=>file.role==='primary').upload_id,harness,fidelity:bundle.fidelity,residue:bundle.residue,files}};
234
377
  }
235
378
  recordStamp(key,ruleId,ruleRevision,backfillMode,stamp){
236
379
  const origin=this.state.origins[key];
@@ -252,7 +395,11 @@ export class TeamsConnector {
252
395
  const loaded=await this.core.session(locator).load(),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
253
396
  const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
254
397
  let messages=(loaded.messages??loaded.turns??[]).map((entry,index)=>{const value=entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key)));return{native_key:entry.id??entry.native_key??String(index),recorded_at:recordedAt(entry),value};});
255
- if(previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index])){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
398
+ // A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
399
+ const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function';
400
+ const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
401
+ const unchanged=previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index]);
402
+ if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
256
403
  let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
257
404
  if(!previous&&backfillPolicy.mode==='none'){const toolTail=trailingToolResults(messages);this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>hash(canonicalJson(x.value))),...(toolTail?{baseline_tool_tail:toolTail}:{}),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>hash(canonicalJson(x.value))),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
258
405
  if(!previous&&backfillPolicy.mode==='since'){
@@ -277,7 +424,9 @@ export class TeamsConnector {
277
424
  if(baselineMoved&&backfillPolicy.mode==='since'&&Number.isSafeInteger(backfillPolicy.since)){
278
425
  const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
279
426
  effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
280
- this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};
427
+ // A tool tail recorded for the old baseline describes positions that no longer hold; it goes with it.
428
+ delete effectivePrevious.baseline_tool_tail;
429
+ this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};delete this.state.origins[key].baseline_tool_tail;
281
430
  }else if(baselineMoved)throw Object.assign(new Error(`the history kept back when rule ${ruleId} started was rewritten; run \`supercode teams sync resume ${ruleId} --backfill all\` (or a since window) to republish this session`),{code:'resync_required'});
282
431
  }
283
432
  if(effectivePrevious?.baseline&&!clearBaseline)messages=messages.filter(x=>!effectivePrevious.baseline.includes(x.native_key));
@@ -285,29 +434,55 @@ export class TeamsConnector {
285
434
  const captureId=`cap_${randomBytes(16).toString('hex')}`,generation=effectivePrevious?.generation??1,sequence=(effectivePrevious?.sequence??0)+1;
286
435
  const begin={native_session_id:nativeSessionId,harness,generation,sequence,capture_id:captureId,previous_digest:effectivePrevious?.digest??null,visibility,rule_id:ruleId,rule_revision:ruleRevision};
287
436
  const parts=[];for(let index=0;index<messages.length;index+=500)parts.push({capture_id:captureId,index:parts.length,messages:messages.slice(index,index+500)});
288
- const artifacts=[];
437
+ let artifacts=[];
289
438
  // Native stores normally live in harness homes outside the repository. The locator is
290
439
  // accepted only when it came from this configured core discovery, and raw bytes are safe
291
440
  // to publish only when the complete native history is in the permitted projection.
292
- let nativeBundle=null;
441
+ let nativeBundle=null,nativeDeferred=false;
293
442
  // A session in a shared SQLite store (OpenCode, Goose) has no file of its own to stat; it is
294
443
  // stable when the core reads back the same messages this capture publishes.
295
444
  const sqliteSource=locator?.storage?.kind==='sqlite';
296
- if((artifactSource||sqliteSource)&&backfillPolicy.mode==='all'&&!effectivePrevious?.baseline&&typeof this.core.translateSession==='function'){
297
- if(artifactSource&&!artifactBefore.isFile())throw Object.assign(new Error('native artifact locator is not a regular file'),{code:'invalid_request'});
298
- const translated=(await this.core.translateSession({locator,target_harness:harness})).artifact;
299
- const changed=new Error('native session changed while it was being captured');
300
- if(artifactSource){const after=statSync(artifactSource);if(artifactBefore.dev!==after.dev||artifactBefore.ino!==after.ino||artifactBefore.size!==after.size||artifactBefore.mtimeMs!==after.mtimeMs)throw Object.assign(changed,{code:'resync_required'});}
301
- else{const digest=entries=>hash(canonicalJson((entries.messages??entries.turns??[]).map(entry=>entry.value??entry)));if(digest(await this.core.session(locator).load())!==digest(loaded))throw Object.assign(changed,{code:'resync_required'});}
302
- const translatedFiles=translated.files?.length?translated.files:[{path:translated.suggested_filename??(artifactSource?basename(artifactSource):`${nativeSessionId}.json`),content:translated.content,role:'primary'}];
303
- if(translatedFiles.filter(file=>file.role==='primary').length!==1)throw Object.assign(new Error('native codec bundle must contain exactly one primary file'),{code:'fidelity_unavailable'});
304
- let total=0;const bundleFiles=[];
305
- for(const file of translatedFiles){const bytes=Buffer.from(file.content,'utf8');total+=bytes.length;if(total>256*1024*1024)throw Object.assign(new Error('native artifact bundle exceeds 256 MiB'),{code:'request_too_large'});const uploadId=`upload_${randomBytes(16).toString('hex')}`,chunks=[];for(let at=0,index=0;at<bytes.length||index===0;at+=1024*1024,index++){const chunk=bytes.subarray(at,Math.min(bytes.length,at+1024*1024));chunks.push({upload_id:uploadId,index,sha256:hash(chunk),bytes_base64:chunk.toString('base64')});if(bytes.length===0)break;}artifacts.push({manifest:{upload_id:uploadId,relative_name:file.path,sha256:hash(bytes),byte_length:bytes.length,chunks:chunks.length,media_type:'application/octet-stream',role:file.role==='source_recovery'?'sidecar':'native'},chunks});bundleFiles.push({upload_id:uploadId,role:file.role});}
306
- nativeBundle={id:bundleFiles.find(file=>file.role==='primary').upload_id,harness,fidelity:translated.fidelity,residue:translated.residue??[],files:bundleFiles};
445
+ // Translating reads the whole native file, so its time grows with the file: a 129 MB Claude Code transcript
446
+ // outlasted the client's 30 s default on every beat and never synced. About 2 s per MB, from 30 s to 10 min.
447
+ const translateTimeoutMs=Math.min(600000,Math.max(30000,Math.ceil((artifactBefore?.size??0)/1e6)*2000));
448
+ if(artifactSource&&nativeWanted){
449
+ if(!artifactBefore.isFile())throw Object.assign(new Error('native artifact locator is not a regular file'),{code:'invalid_request'});
450
+ // A file source's bundle is best effort: when it cannot be attached (the file is live, it moved while it was
451
+ // read, the rule's kept-back baseline is still being cleared, or translating it failed) the messages are
452
+ // published without it and nativeDeferred brings the session back once the file is idle. Coverage speaks
453
+ // of the message history, which is whole either way, so it is unchanged; the capture simply names no
454
+ // artifacts, and the server refuses a native export or continue of that revision (fidelity_unavailable).
455
+ const onDisk=fileStamp(artifactBefore);let bundle=null;
456
+ if(!effectivePrevious?.baseline&&this.nativeReady(key,artifactBefore)){
457
+ const cached=this.state.native_cache[key];
458
+ if(cached?.stamp===onDisk&&cached.files.every(file=>existsSync(join(this.spillDir(),file.spill))))bundle=cached;
459
+ else try{
460
+ const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
461
+ if(fileStamp(statSync(artifactSource))===onDisk){
462
+ bundle={stamp:onDisk,...this.spillTranslated(translated,basename(artifactSource))};
463
+ // Only a translation that took real time is worth keeping; a small file translates again in no time.
464
+ if(artifactBefore.size>=this.nativeIdleBytes){this.state.native_cache[key]=bundle;this.save();this.pruneSpill();}
465
+ }
466
+ }catch(error){
467
+ if(this.abort.signal.aborted)throw error;
468
+ this.lastError=error;process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} published without its native bundle: ${error.message}\n`);
469
+ // The same file is not translated again; a write to it clears this.
470
+ this.state.native_cache[key]={stamp:onDisk,failed:true,files:[]};this.save();this.pruneSpill();
471
+ }
472
+ }
473
+ // The messages were loaded after artifactBefore was read, so they match the bundle only if the file is still that file.
474
+ if(bundle&&fileStamp(statSync(artifactSource))!==onDisk)bundle=null;
475
+ if(bundle)({artifacts,nativeBundle}=this.bundleArtifacts(bundle,harness));else nativeDeferred=true;
476
+ // A retry that still has no bundle would republish the same messages for nothing.
477
+ if(unchanged&&!nativeBundle){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
478
+ }else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
479
+ const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
480
+ const digest=entries=>hash(canonicalJson((entries.messages??entries.turns??[]).map(entry=>entry.value??entry)));if(digest(await this.core.session(locator).load())!==digest(loaded))throw Object.assign(new Error('native session changed while it was being captured'),{code:'resync_required'});
481
+ ({artifacts,nativeBundle}=this.bundleArtifacts(this.spillTranslated(translated,`${nativeSessionId}.json`),harness));
307
482
  }
308
483
  const recordedTimes=messages.map(message=>message.recorded_at).filter(Number.isSafeInteger),commit={capture_id:captureId,parts:parts.length,digest:'',title:descriptor?.title??null,repository:null,reported_user:null,recorded_activity_at:recordedTimes.length?Math.max(...recordedTimes):null,parent_native_id:descriptor?.parent_session_id??null,coverage:effectivePrevious?.baseline&&!clearBaseline?{...coverage,completeness:'partial',reasons:[...new Set([...coverage.reasons,'prefix_not_captured'])]}:coverage,artifacts:artifacts.map(item=>item.manifest),...(nativeBundle?{native_bundle:nativeBundle}:{})};
309
484
  const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
310
- this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
485
+ this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
311
486
  }
312
487
  flush() {
313
488
  const work=this.work.then(()=>this.flushSerial());
@@ -316,7 +491,49 @@ export class TeamsConnector {
316
491
  }
317
492
  // Every outbox error is the connection's, not one session's: a capture's own catch passes it on (syncConfiguredSessions).
318
493
  async flushSerial(){try{return await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}}
319
- async sendCapture(item){const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);for(const artifact of item.artifacts??[])for(const chunk of artifact.chunks)await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);return this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}
494
+ // A spill file is hashed once per version on disk (its stat stamp) and only stat'ed after that. It is lost only
495
+ // when it is missing, the wrong size or damaged; any other failure to read it (a Windows antivirus lock) is thrown,
496
+ // which keeps the capture queued with its bundle for the next flush.
497
+ // A read error that never clears (a lock never released, a lasting EACCES) is retried on five flushes, then the
498
+ // file counts as lost: the outbox is flushed in order, so one unreadable file would otherwise stop every session.
499
+ spillLost(artifact){
500
+ const failures=this.spillReadFailures??=new Map();
501
+ try{const lost=this.spillLostOnce(artifact);failures.delete(artifact.spill.file);return lost;}
502
+ catch(error){
503
+ const count=(failures.get(artifact.spill.file)??0)+1;failures.set(artifact.spill.file,count);
504
+ if(count<5)throw error;
505
+ failures.delete(artifact.spill.file);
506
+ process.stderr.write(`supercode teams: a spilled native artifact could not be read on ${count} flushes (${error.message}); treating it as lost\n`);
507
+ return true;
508
+ }
509
+ }
510
+ spillLostOnce(artifact){
511
+ const path=join(this.spillDir(),artifact.spill.file),missing=error=>error?.code==='ENOENT'||error?.code==='ENOTDIR';
512
+ let stat;try{stat=statSync(path);}catch(error){if(missing(error))return true;throw error;}
513
+ if(!stat.isFile()||stat.size!==artifact.manifest.byte_length)return true;
514
+ const stamp=fileStamp(stat),verified=this.verifiedSpills??=new Map();
515
+ if(verified.get(artifact.spill.file)===stamp)return false;
516
+ let fd;try{fd=this.openSpill(artifact.spill.file);}catch(error){if(missing(error))return true;throw error;}
517
+ try{
518
+ for(let index=0;index<artifact.manifest.chunks;index++){
519
+ const length=Math.max(0,Math.min(CHUNK_BYTES,artifact.manifest.byte_length-index*CHUNK_BYTES)),bytes=Buffer.alloc(length);
520
+ if((length?readSync(fd,bytes,0,length,index*CHUNK_BYTES):0)!==length||hash(bytes)!==artifact.spill.chunk_sha256[index])return true;
521
+ }
522
+ }finally{closeSync(fd);}
523
+ verified.set(artifact.spill.file,stamp);return false;
524
+ }
525
+ // A queued capture whose spilled artifact is gone or damaged on disk would never flush: it is sent without
526
+ // its native bundle instead (its digest resealed), and the session is captured again for one.
527
+ dropLostArtifacts(item){
528
+ if(!(item.artifacts??[]).some(artifact=>artifact.spill&&this.spillLost(artifact)))return;
529
+ process.stderr.write(`supercode teams: a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} lost its native artifact on disk; sending it without one\n`);
530
+ item.artifacts=[];item.commit.artifacts=[];delete item.commit.native_bundle;item.nativeDeferred=true;
531
+ const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.save();
532
+ }
533
+ async sendCapture(item){const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);
534
+ // A capture queued by an older connector holds its chunks inline; a current one reads them from its spill file.
535
+ for(const artifact of item.artifacts??[])for(const chunk of artifact.spill?this.spilledChunks(artifact):artifact.chunks){if(!chunk)throw Object.assign(new Error('a spilled native artifact changed on disk while it was sent'),{code:'resync_required'});await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);}
536
+ return this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}
320
537
  // True when the server refused a queued capture because its sync rule was removed, paused or revised since it
321
538
  // was queued: the capture can never be accepted, so it must not hold every later capture behind it. The
322
539
  // refusal is confirmed against a fresh configuration; any other refusal (a revoked credential, a paused
@@ -328,6 +545,6 @@ export class TeamsConnector {
328
545
  if(!Array.isArray(configuration?.rules))return false;
329
546
  return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
330
547
  }
331
- async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;try{receipt=await this.sendCapture(item);}catch(error){if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();latest=receipt;}return latest;}
548
+ async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);try{receipt=await this.sendCapture(item);}catch(error){if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.nativeDeferred)next.native_deferred=true;else delete next.native_deferred;if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
332
549
  stop(){if(this.timer)clearInterval(this.timer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
333
550
  }
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
2
+ import { join, resolve, sep } from 'node:path';
3
3
 
4
4
  // A continuation materializes a new native session (new ids), so value_lossless is the only
5
5
  // fidelity it delivers.
@@ -19,7 +19,7 @@ export function createNativeContinuationAdapter({ core }) {
19
19
  if (signal?.aborted) throw signal.reason ?? new Error('native continuation was aborted');
20
20
  const files = ticket.artifact.files.map(file => {
21
21
  const path = resolve(directory, file.relative_name);
22
- if (!path.startsWith(`${resolve(directory)}/`)) throw unsupported('native continuation artifact escapes its verified directory');
22
+ if (!path.startsWith(`${resolve(directory)}${sep}`)) throw unsupported('native continuation artifact escapes its verified directory');
23
23
  return { path: file.relative_name, content: readFileSync(path, 'utf8'), role: file.role };
24
24
  });
25
25
  const primary = files.filter(file => file.role === 'primary');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",
@@ -61,7 +61,7 @@
61
61
  "@volter/supercode-frontend": "^0.2.2",
62
62
  "@volter/supercode-harness-sdk": "^0.3.43",
63
63
  "@volter/supercode-terminal": "^0.2.30",
64
- "@volter/identity": "^0.4.0",
64
+ "@volter/identity": "^0.4.1",
65
65
  "@volter/teams": "^0.1.8",
66
66
  "@xterm/addon-serialize": "^0.14.0",
67
67
  "@xterm/headless": "^6.0.0",
@@ -46,6 +46,10 @@ class Reader {
46
46
  const signed64 = (value) => BigInt.asIntN(64, value);
47
47
  const zigzag64 = (value) => (value >> 1n) ^ -(value & 1n);
48
48
 
49
+ // As `json: true`: ProtoJSON spells non-finite floats and doubles "NaN", "Infinity" and "-Infinity"
50
+ // (JSON has no such numbers; JSON.stringify would turn them into null).
51
+ const protoJsonDouble = (value) => Number.isFinite(value) ? value : String(value);
52
+
49
53
  function scalar(type, wire, reader) {
50
54
  switch (type) {
51
55
  case 'string': return utf8.decode(reader.take(Number(reader.varint())));
@@ -59,10 +63,10 @@ function scalar(type, wire, reader) {
59
63
  case 'sint64': return zigzag64(reader.varint()).toString();
60
64
  case 'fixed32': return reader.fixed32();
61
65
  case 'sfixed32': return reader.fixed32() | 0;
62
- case 'float': { const at = reader.pos; reader.take(4); return reader.view.getFloat32(at, true); }
66
+ case 'float': { const at = reader.pos; reader.take(4); return protoJsonDouble(reader.view.getFloat32(at, true)); }
63
67
  case 'fixed64': return reader.fixed64().toString();
64
68
  case 'sfixed64': return signed64(reader.fixed64()).toString();
65
- case 'double': { const at = reader.pos; reader.take(8); return reader.view.getFloat64(at, true); }
69
+ case 'double': { const at = reader.pos; reader.take(8); return protoJsonDouble(reader.view.getFloat64(at, true)); }
66
70
  default: throw new TypeError(`unsupported protobuf scalar ${type} (wire ${wire})`);
67
71
  }
68
72
  }