@volter/supercode-teams 0.3.20 → 0.3.22

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/teams.mjs CHANGED
@@ -33,7 +33,7 @@ import { appendFileSync, createWriteStream, readFileSync, writeFileSync } from '
33
33
  import { spawnSync } from 'node:child_process';
34
34
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
35
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
36
- import { noteStep, slowLogPath, timeCommand } from '@volter/supercode-harness-sdk/slow-log';
36
+ import { noteStep, rotatedLogs, slowLogPath, timeCommand } from '@volter/supercode-harness-sdk/slow-log';
37
37
 
38
38
  // A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
39
39
  if (process.env.SUPERCODE_TEAMS_LOG) {
@@ -197,7 +197,7 @@ async function main() {
197
197
  return at;
198
198
  })();
199
199
  let text = '';
200
- for (const file of [`${slowLogPath()}.1`, slowLogPath()]) {
200
+ for (const file of [...rotatedLogs(), slowLogPath()]) {
201
201
  try { text += readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
202
202
  }
203
203
  const groups = new Map();
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 } 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})).data.kinds);
146
+ for await(const frame of team.events.follow({},{signal:this.abort.signal,after:this.cursor??undefined})){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)`:''}`);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.20",
3
+ "version": "0.3.22",
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.12",
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);
@@ -440,7 +440,7 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
440
440
  */
441
441
  async function viewBindingRoute(api,req,res,auth,ref,requestId){
442
442
  if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential cannot bind sessions');
443
- const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref,now:api.clock.nowMs()});
443
+ const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref,now:api.clock.nowMs(),forInstaller:true});
444
444
  const list=async()=>api.store.query('SELECT issuer,room,conversation,bound_by,created_at FROM session_view_bindings WHERE team_id=? AND session_id=? ORDER BY created_at',[auth.team_id,session.id]);
445
445
  if(req.method==='GET'){
446
446
  const bindings=await list();
@@ -28,7 +28,7 @@ export function createStoreHandlers({ all, get, run, tx }) {
28
28
  const {teamId,principalId,credentialId,ref,now,bound}=args;
29
29
  // `bound`: a workplace's session-view token ({issuer, room, conversation, expiresAt}), which reads only a
30
30
  // session its owner bound to that Room and Conversation; it holds no credential and is no member.
31
- let membership=null,appFence=null,grantSource=null,agentGrantSource=null,agentReader=false;
31
+ let membership=null,appFence=null,grantSource=null,agentGrantSource=null,agentReader=false,installerFor=null;
32
32
  if((args.delegationDepth??0)>16)domainError(401,'authentication_required','delegation lineage is too deep');
33
33
  if(bound){
34
34
  if(!get(`SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.principal_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE i.team_id=? AND a.issuer=? AND i.id=? AND i.created_at=? AND i.state='active' AND a.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin') AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value='delegation.sessions')`,[teamId,bound.issuer,bound.installationId,bound.installedAt]))domainError(401,'authentication_required','integration was uninstalled');
@@ -38,11 +38,15 @@ export function createStoreHandlers({ all, get, run, tx }) {
38
38
  membership=get('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[teamId,principalId]);
39
39
  const identity=get(`SELECT i.state,a.state app_state,m.active,m.role FROM identity_app_credentials c JOIN app_installations i ON i.id=c.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.installer_id WHERE c.credential_id=?`,[credentialId]);
40
40
  if(identity&&(identity.state!=='active'||identity.app_state!=='active'||!identity.active||!['owner','admin'].includes(identity.role)))domainError(401,'authentication_required','identity integration revoked');
41
- const app=get('SELECT ac.*,i.state,a.state app_state,installer.active,installer.role FROM app_credentials ac JOIN app_installations i ON i.id=ac.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE ac.credential_id=?',[credentialId]);
41
+ const app=get('SELECT ac.*,i.state,i.installer_id,a.state app_state,installer.active,installer.role FROM app_credentials ac JOIN app_installations i ON i.id=ac.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE ac.credential_id=?',[credentialId]);
42
42
  if(app){
43
43
  if(app.state!=='active'||app.app_state!=='active'||!app.active||!['owner','admin'].includes(app.role)||!JSON.parse(app.actions_json).includes('sessions.read'))domainError(403,'forbidden','app session read authorization changed');
44
44
  if(get(APP_LINEAGE_REVOKED,[credentialId,now]))domainError(401,'authentication_required','delegation authority revoked');
45
45
  appFence=JSON.parse(app.grants_json);
46
+ // `forInstaller` (the view-binding route only): an app its installer consented to `sessions.access` reaches the
47
+ // installer's own sessions to bind them, as that route already lets it act for them (ADR 0034). Every other read,
48
+ // a transcript, its messages and events, keeps the ordinary grants.
49
+ if(args.forInstaller&&appFence===null&&JSON.parse(app.actions_json).includes('sessions.access'))installerFor=app.installer_id;
46
50
  if(appFence!==null)grantSource=get('SELECT id,principal_id FROM credentials WHERE id=?',[app.subject_credential_id??app.parent_id]);
47
51
  }
48
52
  if(!credential||!membership)domainError(401,'authentication_required','authentication required');
@@ -72,6 +76,7 @@ export function createStoreHandlers({ all, get, run, tx }) {
72
76
  const readable=bound?Boolean(get('SELECT 1 ok FROM session_view_bindings WHERE team_id=? AND session_id=? AND issuer=? AND room=? AND conversation=?',[teamId,root.id,bound.issuer,bound.room,bound.conversation]))&&family.every(session=>session.deleted_at==null):family.every(session=>{
73
77
  if(session.deleted_at!=null)return false;
74
78
  if(!appGrantAllows({app_grants:appFence},'session',session.id,'sessions.read'))return false;
79
+ if(installerFor&&session.custodian_id===installerFor)return true;
75
80
  if(appFence!==null){
76
81
  if(!grantSource)return false;
77
82
  try{authorizedSession({teamId,principalId:grantSource.principal_id,credentialId:grantSource.id,ref:session.id,now,delegationDepth:(args.delegationDepth??0)+1,projectionDepth:1});}catch{return false;}