@volter/supercode-teams 0.3.21 → 0.3.23

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
@@ -16,7 +16,7 @@ import { TeamsConnector, readSyncStatus } from '../connector/index.mjs';
16
16
  import { syncStatusLines } from './workspace/sync-status.mjs';
17
17
  import { calendarCommand } from './workspace/calendar.mjs';
18
18
  import { CONTINUATION_FIDELITY, CONTINUATION_HARNESSES } from '../connector/native-continuation.mjs';
19
- import { MachineEvents } from '../machine-events.mjs';
19
+ import { MachineEvents, PublishedEvents } from '../machine-events.mjs';
20
20
  import { openLocalMachine, nodeRpc } from '../client.mjs';
21
21
  import { TeamsMachine } from '../machine.mjs';
22
22
  import { teamsHome, paths } from '@volter/teams/home';
@@ -1039,6 +1039,9 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
1039
1039
  const connector = new TeamsConnector({ server: context.origin, teamId: context.team_id, machineId: saved.machine.id, sourceId: saved.source.id, credential: saved.credential.token, stateDir: state.directory, core, largeCore, slowCore, liveCore, hostKeyId });
1040
1040
  // A connected machine is a person's: each pane it launches opens a window on its screen.
1041
1041
  const machineEvents = new MachineEvents({home:daemonHome,server:context.origin,teamId:context.team_id,credential:saved.credential.token,machineName:saved.machine.name,binary});
1042
+ // The team's published events, one stream for this install with the connected context's credential, served to this
1043
+ // machine's processes over the daemon's `events` door (@volter/teams/event-client reads there first).
1044
+ if (!context.machine_only) machineEvents.published = new PublishedEvents({ supercodeHome: home, context: contextName, machineId: saved.machine.id });
1042
1045
  const machine = new TeamsMachine({ home: daemonHome, supercode: binary, ...(flags.tmux_socket ? { tmuxSocket: String(flags.tmux_socket) } : {}), link: { server: context.origin, teamId: context.team_id, machineId: saved.machine.id, credential: saved.credential.token, incarnationId: connector.state.incarnation_id, configuration: () => connector.configuration() }, onTurn: (turn) => connector.noteTurn(turn), onTurnWithdrawn: (turn) => connector.withdrawTurn(turn) });
1043
1046
  machine.machineEvents = machineEvents;
1044
1047
  // The machine-health probe mails as this enrolled machine (`sc:<machine>:operator:machine-health`).
package/event-client.mjs CHANGED
@@ -1 +1 @@
1
- export { eventClient } from "@volter/teams/event-client";
1
+ export { eventClient, retryAfterMs } from "@volter/teams/event-client";
@@ -1,9 +1,13 @@
1
1
  // One retained server stream per connector. Subscriptions and pending deliveries
2
2
  // live beside the connector, so neither a restart nor a lost reply duplicates mail.
3
+ // The team's published events are the connector's too: one stream per team install, read by this machine's own
4
+ // processes through the daemon's `events` door (PublishedEvents), never one stream per process.
3
5
  import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
4
6
  import { join } from 'node:path';
5
7
  import { spawn } from 'node:child_process';
6
- import { createHash } from 'node:crypto';
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import { fromContext } from '@volter/teams/context';
10
+ import { retryAfterMs, spentClause } from '@volter/teams/http-client';
7
11
 
8
12
  export class MachineEvents {
9
13
  constructor({home,server,teamId,credential,machineName,binary,env=process.env}) {
@@ -15,7 +19,7 @@ export class MachineEvents {
15
19
  this.abort=new AbortController();this.connected=false;
16
20
  }
17
21
  save(){const temp=`${this.file}.${process.pid}.tmp`;writeFileSync(temp,JSON.stringify(this.state)+'\n',{mode:0o600});renameSync(temp,this.file);}
18
- snapshot(){return {team_id:this.teamId,connected:this.connected,cursor:this.state.cursor,items:Object.values(this.state.items),subscriptions:this.state.subscriptions,pending:this.state.pending.length,error:this.error??null};}
22
+ snapshot(){return {team_id:this.teamId,connected:this.connected,cursor:this.state.cursor,items:Object.values(this.state.items),subscriptions:this.state.subscriptions,pending:this.state.pending.length,error:this.error??null,published:this.published?.status()??null};}
19
23
  subscribe(address,remove=false){
20
24
  if(!/^sc:[^:\s]+:[^:\s]+:[^\s]+$/.test(address??'')||address.split(':')[1]!==this.machine)throw new Error('subscribe a full session or board address on this connector machine');
21
25
  this.state.subscriptions=this.state.subscriptions.filter(value=>value!==address);
@@ -24,20 +28,22 @@ export class MachineEvents {
24
28
  this.save();return this.snapshot();
25
29
  }
26
30
  start(){void this.follow();this.deliveryTimer=setInterval(()=>void this.flush(),5000);this.deliveryTimer.unref?.();}
27
- stop(){this.abort.abort();clearInterval(this.deliveryTimer);}
31
+ stop(){this.abort.abort();clearInterval(this.deliveryTimer);this.published?.stop();}
28
32
  async follow(){
33
+ let refused=0;
29
34
  while(!this.abort.signal.aborted){
30
- const streamAbort=new AbortController();
35
+ const streamAbort=new AbortController();let pause=5000;
31
36
  let deadline=setTimeout(()=>streamAbort.abort(new Error('machine event stream timed out')),45000);
32
37
  try{
33
38
  const suffix=this.state.cursor==null?'':`?after=${this.state.cursor}`;
34
39
  const response=await fetch(`${this.server}/api/v1/teams/${this.teamId}/machines/events${suffix}`,{headers:{Authorization:`Bearer ${this.credential}`,Accept:'text/event-stream'},redirect:'error',signal:AbortSignal.any([this.abort.signal,streamAbort.signal])});
35
- if(!response.ok||!response.headers.get('content-type')?.includes('text/event-stream'))throw new Error(`machine events returned HTTP ${response.status}, expected event stream`);
40
+ if(!response.ok||!response.headers.get('content-type')?.includes('text/event-stream')){const body=response.ok?null:await response.json().catch(()=>null);throw Object.assign(new Error(`machine events returned HTTP ${response.status}, expected event stream`),{retryAfterMs:retryAfterMs(body?.error,response)});}
41
+ refused=0;
36
42
  const reader=response.body.getReader();const decoder=new TextDecoder();let buffer='';
37
43
  try{for(;;){const {value,done}=await reader.read();if(done)break;clearTimeout(deadline);deadline=setTimeout(()=>streamAbort.abort(new Error('machine event stream timed out')),45000);buffer+=decoder.decode(value,{stream:true});if(buffer.length>2*1024*1024)throw new Error('machine event exceeded stream bound');let boundary;while((boundary=buffer.indexOf('\n\n'))>=0){const frame=buffer.slice(0,boundary);buffer=buffer.slice(boundary+2);const data=frame.split('\n').filter(line=>line.startsWith('data:')).map(line=>line.slice(5).trimStart()).join('\n');if(data)await this.receive(JSON.parse(data));}}}finally{await reader.cancel().catch(()=>{});}
38
- }catch(error){if(!this.abort.signal.aborted){this.failingSince??=Date.now();if(this.error!==error.message)process.stderr.write(`${new Date().toISOString()} supercode teams machine events: ${error.message} (cursor ${this.state.cursor})\n`);this.error=error.message;}}
44
+ }catch(error){if(error.retryAfterMs){pause=Math.min(300000,error.retryAfterMs*2**refused);refused++;}if(!this.abort.signal.aborted){this.failingSince??=Date.now();if(this.error!==error.message)process.stderr.write(`${new Date().toISOString()} supercode teams machine events: ${error.message} (cursor ${this.state.cursor})\n`);this.error=error.message;}}
39
45
  clearTimeout(deadline);this.connected=false;
40
- if(!this.abort.signal.aborted)await new Promise(resolve=>{const done=()=>{clearTimeout(timer);this.abort.signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,5000);this.abort.signal.addEventListener('abort',done,{once:true});});
46
+ if(!this.abort.signal.aborted)await new Promise(resolve=>{const done=()=>{clearTimeout(timer);this.abort.signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,pause);this.abort.signal.addEventListener('abort',done,{once:true});});
41
47
  }
42
48
  }
43
49
  async receive(event){
@@ -81,3 +87,150 @@ export class MachineEvents {
81
87
  child.stdin.end(JSON.stringify({op:'send',from:`sc:${this.machine}:operator:teams-events`,from_name:'Teams machine events',to:item.to,id:item.id,queue:item.to.split(':')[2]==='board',notice:true,body:item.body}));
82
88
  });}
83
89
  }
90
+
91
+ // What a local reader's cursor names: this connector's stream continuity (its epoch) and a server event sequence. A
92
+ // cursor of another epoch, or one the ring no longer covers, is resync_required, as the server answers a stale one.
93
+ const LOCAL_CURSOR=/^local\.([0-9a-f]{16})\.(\d+)$/;
94
+ // The ring that resumes a reader from its cursor: the last 4096 events, at most 16 MB of them.
95
+ const RING_EVENTS=4096,RING_BYTES=16*1024*1024;
96
+ // A local reader with more than this waiting unread is slow, as the server counts one (sseRoom): it waits 30 s for
97
+ // the backlog to drain, then resyncs.
98
+ const READER_BACKLOG=8*1024*1024,READER_PATIENCE_MS=30000;
99
+ const keyOf=event=>`${event.resource.kind}\0${event.resource.id}`;
100
+ const sleep=(ms,signal)=>new Promise(resolve=>{const done=()=>{clearTimeout(timer);signal.removeEventListener('abort',done);resolve();},timer=setTimeout(done,ms);signal.addEventListener('abort',done,{once:true});});
101
+
102
+ /**
103
+ * The team's published events (`team.events.follow`), followed once by this connector with the credential of the context
104
+ * it was connected with, and served to this machine's own readers over the daemon's `events` door with the server's
105
+ * frames and semantics: snapshot_begin, snapshot_item, snapshot_end, event, checkpoint, a terminal resync_required or
106
+ * access_revoked; a `kinds` filter as the server applies it; resume from a cursor. The stream opens with its first
107
+ * reader and is unfiltered, so every reader's view is exact; a reader of another credential, or of one source, is not
108
+ * this door's (it is refused, and reads the server itself).
109
+ */
110
+ export class PublishedEvents {
111
+ constructor({supercodeHome,context,machineId=null,log=message=>process.stderr.write(`${new Date().toISOString()} supercode teams published events: ${message}\n`)}){
112
+ Object.assign(this,{supercodeHome,contextName:context,machineId,log});
113
+ this.abort=new AbortController();this.started=false;this.readers=new Set();this.catalogue=null;this.error=null;
114
+ // The stream's state: the server cursor it resumes from, each resource's latest event, and the recent events.
115
+ this.cursor=null;this.epoch=null;this.live=false;this.pending=null;this.resources=new Map();this.ring=[];this.ringBytes=0;this.head=0;this.floor=0;
116
+ }
117
+ selected(){return fromContext({supercodeHome:this.supercodeHome,name:this.contextName,fetch:globalThis.fetch});}
118
+ /** Whether a follow is this connector's to serve exactly: its server, team and credential, every source. */
119
+ serves(params){
120
+ let context;try{context=this.selected().context;}catch{return false;}
121
+ return !context.machine_only&&params.server_id===context.server_id&&params.team_id===context.team_id&&params.credential_ref===context.credential_ref&&params.source==null
122
+ &&(params.kinds==null||Array.isArray(params.kinds)&&params.kinds.length>0&&params.kinds.every(kind=>typeof kind==='string'&&/^[a-z_]+\.[a-z_]+$/.test(kind)))
123
+ &&(params.after==null||typeof params.after==='string');
124
+ }
125
+ status(){return {context:this.contextName,connected:this.live,epoch:this.epoch,head:this.head,resources:this.resources.size,ring:this.ring.length,readers:this.readers.size,error:this.error};}
126
+ serve(stream,accept,refuse){
127
+ const params=stream.params??{};
128
+ if(!this.serves(params))return refuse('this connector does not follow published events for that credential');
129
+ accept();
130
+ const kinds=params.kinds==null?null:new Set(params.kinds);
131
+ const reader={stream,kinds,resources:kinds&&new Set([...kinds].map(kind=>kind.split('.')[0])),after:params.after??null,at:0,ready:false,admitting:false};
132
+ this.readers.add(reader);stream.on('close',()=>this.readers.delete(reader));
133
+ this.start();
134
+ if(this.live)void this.admit(reader);
135
+ }
136
+ start(){if(this.started)return;this.started=true;void this.follow();}
137
+ stop(){this.abort.abort();for(const reader of this.readers)reader.stream.close('connector stopped');this.readers.clear();}
138
+ async follow(){
139
+ let refused=0;
140
+ while(!this.abort.signal.aborted){
141
+ let pause=5000;
142
+ try{
143
+ const {client,context}=this.selected();client.machine=this.machineId;
144
+ const team=client.team(context.team_id);
145
+ this.catalogue??=new Set((await team.events.catalogue({signal:this.abort.signal,caller:'connector published-events catalogue'})).data.kinds);
146
+ for await(const frame of team.events.follow({},{signal:this.abort.signal,after:this.cursor??undefined,caller:'connector published-events'})){refused=0;this.receive(frame);if(frame.terminal)break;}
147
+ this.error='event stream ended';
148
+ }catch(error){
149
+ // Refused for load, it waits out the server's retry_after, doubling while refused (to 5 min), cursor kept.
150
+ const asked=retryAfterMs(error);if(asked){pause=Math.min(300000,asked*2**refused);refused++;}
151
+ if(error.status===409){this.cursor=null;this.reset('resync_required','upstream_resync');}
152
+ if(!this.abort.signal.aborted){const message=`${error.code??'error'}: ${error.message}`;if(message!==this.error)this.log(`${message}${asked?` (retrying in ${Math.round(pause/1000)} s)`:''}${spentClause(error)}`);this.error=message;}
153
+ }
154
+ this.down(this.error??'event stream ended');
155
+ if(!this.abort.signal.aborted)await sleep(pause,this.abort.signal);
156
+ }
157
+ }
158
+ receive(frame){
159
+ if(frame.type==='snapshot_begin'){if(this.epoch)this.reset('resync_required','upstream_resync');this.pending=new Map();return;}
160
+ if(frame.type==='snapshot_item'){(this.pending??this.resources).set(keyOf(frame.data),{key:keyOf(frame.data),sequence:frame.sequence,data:frame.data});return;}
161
+ if(frame.type==='snapshot_end'){
162
+ this.resources=this.pending??new Map();this.pending=null;this.cursor=frame.cursor;
163
+ this.head=0;for(const item of this.resources.values())this.head=Math.max(this.head,item.sequence);
164
+ this.floor=this.head;this.ring=[];this.ringBytes=0;this.epoch=randomBytes(8).toString('hex');this.up();return;
165
+ }
166
+ if(frame.type==='event'){
167
+ this.cursor=frame.cursor;if(!this.epoch||frame.sequence<=this.head)return;
168
+ const key=keyOf(frame.data),old=this.resources.get(key);
169
+ if(!old||old.sequence<frame.sequence)this.resources.set(key,{key,sequence:frame.sequence,data:frame.data});
170
+ const bytes=JSON.stringify(frame.data).length;this.ring.push({sequence:frame.sequence,data:frame.data,bytes});this.ringBytes+=bytes;this.head=frame.sequence;
171
+ while(this.ring.length>RING_EVENTS||this.ringBytes>RING_BYTES){const dropped=this.ring.shift();this.ringBytes-=dropped.bytes;this.floor=dropped.sequence;}
172
+ if(!this.live){this.up();return;}
173
+ for(const reader of this.readers)if(reader.ready){reader.at=frame.sequence;if(this.wants(reader,frame.data))this.put(reader,{type:'event',sequence:frame.sequence,cursor:this.local(frame.sequence),data:frame.data});}
174
+ return;
175
+ }
176
+ if(frame.type==='checkpoint'){
177
+ this.cursor=frame.cursor;if(!this.epoch)return;
178
+ if(!this.live){this.up();return;}
179
+ for(const reader of this.readers)if(reader.ready)this.put(reader,{type:'checkpoint',cursor:this.local(this.head)});
180
+ return;
181
+ }
182
+ if(frame.terminal){this.cursor=null;this.reset(frame.type==='access_revoked'?'access_revoked':'resync_required',frame.reason??'upstream');}
183
+ }
184
+ local(sequence){return `local.${this.epoch}.${sequence}`;}
185
+ wants(reader,event){return !reader.kinds||reader.kinds.has(event.kind);}
186
+ // As the server's snapshot: a resource whose kind a subscribed kind names, or whose latest event is a subscribed kind.
187
+ inSnapshot(reader,event){return !reader.kinds||reader.resources.has(event.resource.kind)||reader.kinds.has(event.kind);}
188
+ up(){this.live=true;this.error=null;for(const reader of this.readers)if(!reader.ready&&!reader.admitting)void this.admit(reader);}
189
+ // The stream dropped: readers it was serving are let go (not told to resync) and resume from their cursors once it
190
+ // is back; a reader still waiting for it waits on.
191
+ down(message){this.live=false;for(const reader of [...this.readers])if(reader.ready||reader.admitting){this.readers.delete(reader);reader.stream.close(`unavailable: ${message}`);}}
192
+ // The stream's continuity ended (a new snapshot, a revoked credential): every reader's cursor is void.
193
+ reset(type,reason){this.epoch=null;this.live=false;for(const reader of [...this.readers])this.terminal(reader,type,reason);}
194
+ terminal(reader,type,reason){this.readers.delete(reader);if(reader.stream.closed)return;try{reader.stream.write(JSON.stringify({type,reason,terminal:true}));}catch{}reader.stream.close(type);}
195
+ put(reader,frame){
196
+ if(reader.stream.closed)return false;
197
+ if((reader.stream.channel?.socket?.writableLength??0)>READER_BACKLOG){this.terminal(reader,'resync_required','slow_reader');return false;}
198
+ try{reader.stream.write(JSON.stringify(frame));return true;}catch{return false;}
199
+ }
200
+ async write(reader,frame){
201
+ const socket=reader.stream.channel?.socket;
202
+ if(socket&&socket.writableLength>READER_BACKLOG/2){
203
+ const drained=await new Promise(resolve=>{const done=ok=>{clearTimeout(timer);socket.off('drain',ready);socket.off('close',gone);resolve(ok);},ready=()=>done(true),gone=()=>done(false),timer=setTimeout(()=>done(false),READER_PATIENCE_MS);socket.on('drain',ready);socket.on('close',gone);});
204
+ if(!drained){this.terminal(reader,'resync_required','slow_reader');return false;}
205
+ }
206
+ return this.put(reader,frame);
207
+ }
208
+ async admit(reader){
209
+ const epoch=this.epoch;reader.admitting=true;
210
+ try{
211
+ if(reader.kinds&&this.catalogue&&[...reader.kinds].some(kind=>!this.catalogue.has(kind))){this.readers.delete(reader);reader.stream.close('invalid_request: invalid event subscription');return;}
212
+ if(reader.after==null){
213
+ const items=[...this.resources.values()].filter(item=>this.inSnapshot(reader,item.data)).sort((a,b)=>a.key<b.key?-1:a.key>b.key?1:0);
214
+ reader.at=this.head;
215
+ if(!await this.write(reader,{type:'snapshot_begin'}))return;
216
+ for(const item of items){if(this.epoch!==epoch)return this.terminal(reader,'resync_required','upstream_resync');if(!await this.write(reader,{type:'snapshot_item',sequence:item.sequence,data:item.data}))return;}
217
+ if(this.epoch!==epoch)return this.terminal(reader,'resync_required','upstream_resync');
218
+ if(!await this.write(reader,{type:'snapshot_end',cursor:this.local(reader.at)}))return;
219
+ }else{
220
+ const match=LOCAL_CURSOR.exec(reader.after),at=match?Number(match[2]):NaN;
221
+ if(!match||match[1]!==epoch||at<this.floor||at>this.head)return this.terminal(reader,'resync_required','cursor');
222
+ reader.at=at;
223
+ }
224
+ // Catch up from the ring, then go live: the last step does not wait, so no event falls between the two.
225
+ for(;;){
226
+ if(this.epoch!==epoch||reader.at<this.floor)return this.terminal(reader,'resync_required','retention');
227
+ let low=0,high=this.ring.length;while(low<high){const mid=(low+high)>>1;if(this.ring[mid].sequence<=reader.at)low=mid+1;else high=mid;}
228
+ const next=this.ring[low];if(!next)break;
229
+ reader.at=next.sequence;
230
+ if(this.wants(reader,next.data)&&!await this.write(reader,{type:'event',sequence:next.sequence,cursor:this.local(next.sequence),data:next.data}))return;
231
+ }
232
+ if(!this.live||reader.stream.closed)return;
233
+ reader.ready=true;this.put(reader,{type:'checkpoint',cursor:this.local(this.head)});
234
+ }finally{reader.admitting=false;}
235
+ }
236
+ }
package/machine.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  // stream "product" a target another product serves here (products.mjs), admitted first
16
16
  // stream "exec" | "sftp" | "exec-server" operators: a pilot's tool process or file view
17
17
  // stream "workspace" operators: a project leaving or arriving
18
+ // stream "events" local callers only: the team's published events from the one stream the connector holds
18
19
  //
19
20
  // Two doors reach it. The LOCAL door is an owner-only Unix socket in the home:
20
21
  // whoever can open it is this OS user, an operator. The REMOTE door is the
@@ -267,6 +268,14 @@ export class TeamsMachine extends MachineCore {
267
268
  for (const name of ['exec', 'sftp', 'exec-server']) this.registerDoor(name, (entry, stream, accept, refuse) => this.#serveExecution(entry, stream, accept, refuse));
268
269
  this.registerDoor('workspace', (entry, stream, accept, refuse) => this.#serveWorkspace(entry, stream, accept, refuse));
269
270
  this.registerDoor('mail', (entry, stream, accept, refuse) => this.#serveMail(entry, stream, accept, refuse));
271
+ // This machine's processes read the team's published events here, from the one stream its connector holds
272
+ // (machine-events.mjs PublishedEvents); a caller it refuses reads the server itself.
273
+ this.registerDoor('events', (entry, stream, accept, refuse) => {
274
+ if (entry.principal !== 'local') return refuse('published events are served to this machine\'s own processes');
275
+ const published = this.machineEvents?.published;
276
+ if (!published) return refuse('this daemon is not a connector following published events');
277
+ return published.serve(stream, accept, refuse);
278
+ });
270
279
  }
271
280
 
272
281
  // ---- lifecycle ------------------------------------------------------------
@@ -808,8 +817,12 @@ export class TeamsMachine extends MachineCore {
808
817
  // A reused pane key still gets a new receipt baseline. A late hook from
809
818
  // its previous process cannot publish evidence for this launch.
810
819
  const binding = join(dir, `${pane}-${randomBytes(9).toString('base64url')}.json`);
811
- const initialInput = params.initialInput && !args.includes('--') &&
812
- !args.some((arg) => arg === '--remote' || arg.startsWith('--remote=')) ? params.initialInput : null;
820
+ // Claude records an argv prompt verbatim when it runs at once, but trimmed
821
+ // when it queues behind work restored on resume (a pending task
822
+ // notification). Sending the trimmed text makes both records equal it.
823
+ const prompt = params.kind === 'claude-code' && typeof params.initialInput === 'string' ? params.initialInput.trim() : params.initialInput;
824
+ const initialInput = prompt && !args.includes('--') &&
825
+ !args.some((arg) => arg === '--remote' || arg.startsWith('--remote=')) ? prompt : null;
813
826
  writeFileSync(binding, JSON.stringify({ kind: params.kind, pane, by, program: params.program, arguments: args, initialInput }), { mode: 0o600 });
814
827
  if (initialInput) nativeInput({ binding, kind: params.kind, text: initialInput, startedAt: Date.now() });
815
828
  const claudeCommand = (next) => launchCommand(params.program, initialInput ? [...next, '--', initialInput] : next);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
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",
@@ -66,7 +66,7 @@
66
66
  "@volter/supercode-health": "^0.1.0",
67
67
  "@volter/supercode-terminal": "^0.2.54",
68
68
  "@volter/supercode-trace": "^0.1.0",
69
- "@volter/teams": "0.2.11",
69
+ "@volter/teams": "0.2.13",
70
70
  "@xterm/addon-serialize": "^0.14.0",
71
71
  "@xterm/headless": "^6.0.0",
72
72
  "protobufjs": "8.8.0",
@@ -3,21 +3,18 @@ import { appAuthentication } from '@volter/teams/server/apps';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { validateListQuery } from '../contracts/index.mjs';
5
5
  import { cursorHash, cursorToken, liveBindings } from './catalog.mjs';
6
- import { sseRoom, writeSse } from '@volter/teams/server/events';
6
+ import { sseRoom, writeSse, holdStream } from '@volter/teams/server/events';
7
7
 
8
- function openStream(api, req, res, auth, requestId, fail) {
9
- const count = api.openStreams.get(auth.credential_id) ?? 0;
10
- if (count >= 10) fail(429, 'rate_limited', 'too many open event streams', { retry_after_seconds: 1 });
11
- api.openStreams.set(auth.credential_id, count + 1);
8
+ // Counted per team install (holdStream): a refusal names the server's retry-after, never a tight retry.
9
+ async function openStream(api, req, res, auth, requestId) {
10
+ const release = await holdStream(api, auth, req);
12
11
  let closed = false;
13
12
  const cleanups = [];
14
13
  const close = () => {
15
14
  if (closed) return;
16
15
  closed = true;
17
16
  for (const cleanup of cleanups) cleanup();
18
- const next = (api.openStreams.get(auth.credential_id) ?? 1) - 1;
19
- if (next > 0) api.openStreams.set(auth.credential_id, next);
20
- else api.openStreams.delete(auth.credential_id);
17
+ release();
21
18
  res.end();
22
19
  };
23
20
  req.once('close', close);
@@ -124,7 +121,7 @@ export async function handleSessionEvents({ api, req, res, auth, id, url, reques
124
121
  ]);
125
122
  if (delivered > team.journal_revision || delivered < team.journal_revision && (retained.first_revision === null || delivered < retained.first_revision - 1)) fail(409, 'resync_required', 'event cursor is outside retained journal');
126
123
  }
127
- const stream = openStream(api, req, res, auth, requestId, fail);
124
+ const stream = await openStream(api, req, res, auth, requestId);
128
125
  if (after === null && !await stream.write({ type: 'snapshot', cursor: initial.event_cursor, revision: initial.revision, data: initial })) return;
129
126
  const enqueue = streamQueue(stream, async () => {
130
127
  if(!auth.bound&&!await appAuthentication(api,auth))fail(401,'authentication_required','app authorization changed');
@@ -221,7 +218,7 @@ export async function handleCatalogEvents({ api, req, res, auth, url, requestId,
221
218
  const query = qobj(url); delete query.after;
222
219
  validateListQuery(query);
223
220
  if (query.cursor) fail(400, 'invalid_request', 'catalog streams use current windows, not page cursors');
224
- const stream = openStream(api, req, res, auth, requestId, fail);
221
+ const stream = await openStream(api, req, res, auth, requestId);
225
222
  let lastCursor = null;
226
223
  const enqueue = streamQueue(stream, async () => {
227
224
  const page = await catalogWindow(api, auth, query);