@volter/supercode-teams 0.2.179 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { randomBytes, createHash } from 'node:crypto';
4
4
  import { canonicalJson, carryDigest } from '../contracts/index.mjs';
5
5
  import { EffectConsumer } from './effects.mjs';
6
6
  import { NativeEffects } from './native-effects.mjs';
7
+ import { consumeAgentClosures } from './agent-closures.mjs';
7
8
 
8
9
  const SYNC_PROBLEM_LIMIT=20;
9
10
  // Live follows are bounded. The harness SDK lets at most 64 subscription opens await replies at once (sdk/typescript
@@ -172,7 +173,7 @@ export class TeamsConnector {
172
173
  this.state.sequence += 1;
173
174
  const reply = await this.request('POST', `/machines/${this.machineId}/heartbeat`, {
174
175
  incarnation_id: this.state.incarnation_id, sequence: this.state.sequence,
175
- bindings: [...this.nativeEffects.published(), ...await bindings()],
176
+ bindings: [...(this.activityBindings?.values()??[]), ...this.nativeEffects.published(), ...await bindings()],
176
177
  roots: this.localRoots.map(({id,machine_id,name,harnesses})=>({id,machine_id,name,harnesses})),
177
178
  ...(this.hostKeyId ? { host_key_id: this.hostKeyId } : {}),
178
179
  // What each synced session's harness is doing (its turn: working, idle, …), from the last discovery.
@@ -189,9 +190,19 @@ export class TeamsConnector {
189
190
  beating = next.catch(() => {});
190
191
  return next;
191
192
  };
193
+ this.publishActivityHeartbeat=()=>void heartbeat().catch(error=>{this.lastError=error;});
194
+ this.activityBindings=new Map();
195
+ this.activityEvent=event=>{
196
+ if(event.subscription!==this.activitySubscription)return;
197
+ for(const activity of event.activities??[])this.recordActivityBinding(activity);
198
+ this.publishActivityHeartbeat();
199
+ };
200
+ this.core.on?.('sessionActivityEvent',this.activityEvent);
192
201
  const pass = async () => {
193
202
  const configuration = await this.configuration();
194
203
  await this.syncConfiguredSessions(configuration, this.localRoots);
204
+ await this.publishSessionProfiles();
205
+ await consumeAgentClosures(this);
195
206
  await heartbeat();
196
207
  await this.pollEffects();
197
208
  };
@@ -252,7 +263,7 @@ export class TeamsConnector {
252
263
  // A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
253
264
  // reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
254
265
  // machine; rules over the same root and harnesses share the scan.
255
- const discoveries=new Map(),followCandidates=[],discovered=new Set(),activity=[];
266
+ const discoveries=new Map(),followCandidates=[],discovered=new Set(),activity=[],activityLocators=new Map();
256
267
  this.liveFiles??=new Map();
257
268
  this.turnStates??=new Map();
258
269
  for(const rule of activeRules.values()){
@@ -275,6 +286,8 @@ export class TeamsConnector {
275
286
  const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
276
287
  if(!locator||!nativeSessionId||!harness)continue;
277
288
  discovered.add(`${harness}:${nativeSessionId}`);
289
+ activityLocators.set(`${harness}:${nativeSessionId}`,locator);
290
+ if(descriptor.activity)this.recordActivityBinding({...descriptor.activity,harness,session_id:nativeSessionId});
278
291
  // A session's turn state as its harness reports it, and since when this connector has seen it so.
279
292
  const turn=descriptor.activity?.turn;
280
293
  if(typeof turn==='string'&&turn&&activity.length<4096){const key=`${harness}:${nativeSessionId}`,held=this.turnStates.get(key),since=held?.turn===turn?held.since:this.clock.nowMs();this.turnStates.set(key,{turn,since});activity.push({harness,native_id:nativeSessionId,turn,since});}
@@ -303,11 +316,31 @@ export class TeamsConnector {
303
316
  }
304
317
  for(const [key,options] of this.liveFiles)if(!discovered.has(key)||!activeRules.has(options.ruleId))this.liveFiles.delete(key);
305
318
  this.nativeEffects.endDiscovery();
319
+ await this.followSessionActivity([...activityLocators.values()]);
306
320
  for(const key of this.turnStates.keys())if(!discovered.has(key))this.turnStates.delete(key);
307
321
  this.reportedActivity=activity;
308
322
  this.pruneNativeCache(discovered);
309
323
  this.rotateFollowers(followCandidates);
310
324
  }
325
+ recordActivityBinding(activity){
326
+ if(!activity?.harness||!activity.session_id)return;
327
+ const key=`${activity.harness}:${activity.session_id}`,id=`activity_${hash(`${this.machineId}:${this.state.incarnation_id}:${key}`).slice(0,32)}`;
328
+ const previous=this.activityBindings?.get(id),presence=activity.presence;
329
+ const state=presence==='running'?'running':presence==='persisted'&&['running','stopped'].includes(previous?.state)?'stopped':'unknown';
330
+ this.activityBindings?.set(id,{id,machine_id:this.machineId,incarnation_id:this.state.incarnation_id,harness:activity.harness,native_session_id:activity.session_id,native_runtime_id:activity.session_id,state,observed_at:this.clock.nowMs(),native_capabilities:[],evidence:activity.evidence??null,turn:activity.turn??'unknown'});
331
+ }
332
+ async followSessionActivity(locators){
333
+ if(typeof this.core.subscribeSessionActivity!=='function'||typeof this.core.unsubscribeSessionActivity!=='function')return;
334
+ if(!locators.length)return;
335
+ if(locators.length>2048)locators=locators.filter(locator=>this.activityBindings?.get(`activity_${hash(`${this.machineId}:${this.state.incarnation_id}:${locator.harness}:${locator.session_id}`).slice(0,32)}`)?.state==='running').slice(0,2048);
336
+ if(!locators.length)return;
337
+ const fingerprint=locators.map(item=>`${item.harness}:${item.session_id}`).sort().join('\n');
338
+ if(fingerprint===this.activityFingerprint)return;
339
+ const opened=await this.core.subscribeSessionActivity(locators),previous=this.activitySubscription;
340
+ this.activitySubscription=opened.subscription;this.activityFingerprint=fingerprint;
341
+ for(const activity of opened.initial??[])this.recordActivityBinding(activity);
342
+ if(previous)await this.core.unsubscribeSessionActivity(previous);
343
+ }
311
344
  captureViewedFiles(){
312
345
  if(this.abort.signal.aborted)return;
313
346
  this.liveFileCaptures??=new Set();this.liveFileStamps??=new Map();
@@ -619,6 +652,29 @@ export class TeamsConnector {
619
652
  const large=(artifactBefore?.size??0)>=LARGE_CAPTURE_BYTES;
620
653
  const loaded=preloaded?preloaded.loaded:await this.core.session(locator).load(captureRead(harness,large?{timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)}:{})),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
621
654
  const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
655
+ if(Array.isArray(loaded.resolved_profile_history)&&loaded.resolved_profile_history.length) {
656
+ const seen=this.state.profile_history_seen??={},known=new Set(seen[key]??[]);
657
+ const cutoff=backfillPolicy.mode==='none'?rule.capture_after:backfillPolicy.mode==='since'?backfillPolicy.since:null;
658
+ for(const observation of loaded.resolved_profile_history) {
659
+ if(backfillPolicy.mode==='none'&&!Number.isSafeInteger(cutoff))continue;
660
+ const at=Date.parse(observation.at??'');
661
+ if(Number.isSafeInteger(cutoff)&&(!Number.isSafeInteger(at)||at<cutoff))continue;
662
+ const id=`profile_${hash(canonicalJson([this.sourceId,key,observation.native_key,observation.at,observation.profile])).slice(0,32)}`;
663
+ if(known.has(id))continue;
664
+ known.add(id);
665
+ (this.state.profile_outbox??=[]).push({session:key,profile:observation.profile,observed_at:Number.isSafeInteger(at)?at:this.clock.nowMs(),observation_id:id});
666
+ }
667
+ seen[key]=[...known];this.save();
668
+ }
669
+ if(loaded.resolved_profile){
670
+ const observed=this.state.session_profiles??={},profile=loaded.resolved_profile,digest=hash(canonicalJson(profile));
671
+ if(observed[key]?.digest!==digest){
672
+ observed[key]={digest};
673
+ const latest=(this.state.profile_outbox??[]).filter(item=>item.session===key).at(-1);
674
+ if(!latest||canonicalJson(latest.profile)!==canonicalJson(profile))(this.state.profile_outbox??=[]).push({session:key,profile,observed_at:this.clock.nowMs(),observation_id:`profile_${randomBytes(16).toString('hex')}`});
675
+ this.save();
676
+ }
677
+ }
622
678
  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};});
623
679
  this.labelTurns(key,loaded.messages??loaded.turns??[],messages);
624
680
  if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
@@ -626,7 +682,7 @@ export class TeamsConnector {
626
682
  const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
627
683
  const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
628
684
  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]);
629
- if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
685
+ if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
630
686
  let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
631
687
  // A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
632
688
  // whatever a session holds when first seen: a session begun after the rule is published from its first message.
@@ -717,7 +773,22 @@ export class TeamsConnector {
717
773
  }
718
774
  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}:{})};
719
775
  const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
720
- 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();
776
+ 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();const receipt=await this.flushSerial();await this.publishSessionProfile(key);return receipt;
777
+ }
778
+ async publishSessionProfile(session){
779
+ const observation=this.state.profile_outbox?.find(item=>item.session===session);if(!observation)return;
780
+ try{
781
+ await this.request('POST',`/machines/${this.machineId}/session-profiles`,{session,profile:observation.profile,observed_at:observation.observed_at,observation_id:observation.observation_id});
782
+ this.state.profile_outbox=this.state.profile_outbox.filter(item=>item!==observation);this.save();
783
+ }catch(error){
784
+ // A launch can be captured before its identity declaration reaches Teams. Retain the observation for retry.
785
+ observation.error=errorSummary(error,this.clock.nowMs());observation.attempted_at=this.clock.nowMs();this.save();
786
+ if(error.status!==409&&error.status!==404)throw error;
787
+ }
788
+ }
789
+ async publishSessionProfiles(){
790
+ const first=new Map();for(const item of this.state.profile_outbox??[])if(!first.has(item.session))first.set(item.session,item);
791
+ for(const item of [...first.values()].sort((a,b)=>(a.attempted_at??0)-(b.attempted_at??0)).slice(0,100))await this.publishSessionProfile(item.session);
721
792
  }
722
793
  flush() {
723
794
  const work=this.work.then(()=>this.flushSerial());
@@ -826,5 +897,5 @@ export class TeamsConnector {
826
897
  }
827
898
  async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);this.refitParts(item);try{receipt=await this.sendCapture(item);}catch(error){if(error.code==='storage_pressure'&&this.deferNativeUnderPressure(item))continue;if(error.status===409&&error.code==='carry_mismatch'){const key=`${item.begin.harness}:${item.begin.native_session_id}`;this.lastError=error;process.stderr.write(`supercode teams: the server does not hold what a capture of ${item.begin.harness} session ${item.begin.native_session_id} carried; it goes whole next time\n`);this.state.outbox.shift();if(this.state.origins[key])this.state.origins[key]={...this.state.origins[key],observed_keys:[],observed_digests:[],carry_refused:true};this.save();continue;}if(error.status===400&&error.code==='invalid_request'||error.status===413){this.lastError=error;this.noteSessionProblem({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id,ruleId:item.begin.rule_id??null},error);(this.droppedCaptures??=new Set()).add(`${item.begin.harness}:${item.begin.native_session_id}`);process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused${error.status===413?' (one message is larger than a request may be)':' as malformed'}: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}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.begin.carry)delete next.carry_refused;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();// A queued capture is often flushed by another session's capture: its own session's problem ends with it.
828
899
  this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
829
- stop(){try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.liveCaptureTimer)clearInterval(this.liveCaptureTimer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
900
+ stop(){this.core.off?.('sessionActivityEvent',this.activityEvent);if(this.activitySubscription)void this.core.unsubscribeSessionActivity(this.activitySubscription).catch(()=>{});try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.liveCaptureTimer)clearInterval(this.liveCaptureTimer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
830
901
  }
package/context.mjs CHANGED
@@ -1,34 +1 @@
1
- import { mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, existsSync, chmodSync } from 'node:fs';
2
- import { randomBytes } from 'node:crypto';
3
- import { join, dirname } from 'node:path';
4
- import { TeamsClient } from './workspace-client.mjs';
5
- const NAME=/^[a-z][a-z0-9_-]{0,63}$/;
6
- const ID=/^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
7
- function normalizeOrigin(value){const url=new URL(value);if(url.username||url.password||url.pathname!=='/'||url.search||url.hash)throw new Error('server must be a bare HTTP(S) origin');if(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','::1','[::1]','localhost'].includes(url.hostname)))throw new Error('non-loopback servers require HTTPS');return url.origin;}
8
- export function workspacePaths(supercodeHome){const root=join(supercodeHome,'teams','workspaces');return {root,contexts:join(root,'contexts.json'),credentials:join(root,'credentials'),connectors:join(root,'connectors')};}
9
- function locked(path,fn){mkdirSync(dirname(path),{recursive:true,mode:0o700});chmodSync(dirname(path),0o700);const lock=path+'.lock';let fd;try{fd=openSync(lock,'wx',0o600);}catch(e){throw Object.assign(new Error(`${path} is locked by another writer`),{code:'EBUSY',cause:e});}try{return fn();}finally{closeSync(fd);unlinkSync(lock);}}
10
- function read(path,fallback){return existsSync(path)?JSON.parse(readFileSync(path,'utf8')):fallback;}
11
- function atomic(path,value){const tmp=`${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`,fd=openSync(tmp,'wx',0o600);try{writeFileSync(fd,JSON.stringify(value,null,2)+'\n');}finally{closeSync(fd);}try{renameSync(tmp,path);}catch(error){try{unlinkSync(tmp);}catch{}throw error;}chmodSync(path,0o600);}
12
- export class WorkspaceContexts { constructor(supercodeHome){this.paths=workspacePaths(supercodeHome);} read(){return read(this.paths.contexts,{version:1,current:null,contexts:{}});} create(name,value){if(!NAME.test(name)||name==='local')throw new Error('context name must match [a-z][a-z0-9_-]{0,63} and cannot be local');return locked(this.paths.contexts,()=>{const doc=this.read();if(doc.contexts[name])throw new Error(`context ${name} already exists`);doc.contexts[name]=value;atomic(this.paths.contexts,doc);return value;});} use(name){return locked(this.paths.contexts,()=>{const doc=this.read();if(name!==null&&name!=='local'&&!doc.contexts[name])throw new Error(`unknown context ${name}`);doc.current=name;atomic(this.paths.contexts,doc);});} remove(name){return locked(this.paths.contexts,()=>{const doc=this.read();delete doc.contexts[name];if(doc.current===name)doc.current=null;atomic(this.paths.contexts,doc);});} writeCredential(id,value){mkdirSync(this.paths.credentials,{recursive:true,mode:0o700});locked(join(this.paths.credentials,`${id}.json`),()=>atomic(join(this.paths.credentials,`${id}.json`),value));} credential(id){return read(join(this.paths.credentials,`${id}.json`),null);} }
13
- WorkspaceContexts.prototype.upsert=function(name,value){if(!NAME.test(name)||name==='local')throw new Error('context name must match [a-z][a-z0-9_-]{0,63} and cannot be local');return locked(this.paths.contexts,()=>{const doc=this.read();doc.contexts[name]=value;atomic(this.paths.contexts,doc);return value;});};
14
- WorkspaceContexts.prototype.createAuthenticated=async function(name,{origin,teamId,credentialRef,fetch}){
15
- if(!ID.test(teamId)||!/^cred_[A-Za-z0-9_-]+$/.test(credentialRef))throw new Error('team and credential reference must be immutable IDs');
16
- const normalized=normalizeOrigin(origin),credential=this.credential(credentialRef);
17
- if(!credential?.token)throw Object.assign(new Error(`credential ${credentialRef} is missing`),{code:'authentication_required'});
18
- if(credential.origin!==normalized)throw Object.assign(new Error('credential origin does not match requested server'),{code:'authentication_required'});
19
- const client=new TeamsClient({server:normalized,credential:credential.token,fetch});
20
- const me=await client.me();
21
- if(credential.server_id&&credential.server_id!==me.server_id)throw Object.assign(new Error('credential server audience does not match'),{code:'authentication_required'});
22
- const membership=me.teams.find(item=>item.team.id===teamId);
23
- if(!membership)throw Object.assign(new Error('credential is not a member of the requested team'),{code:'forbidden'});
24
- return this.create(name,{origin:normalized,server_id:me.server_id,team_id:teamId,principal_id:me.principal.id,credential_ref:credentialRef});
25
- };
26
- WorkspaceContexts.prototype.connector=function(serverId,teamId){
27
- if(!ID.test(serverId)||!ID.test(teamId))throw new Error('server and team must be immutable IDs');
28
- const directory=join(this.paths.connectors,serverId,teamId),path=join(directory,'enrollment.json');
29
- return {directory,read:()=>read(path,null),write:value=>{mkdirSync(directory,{recursive:true,mode:0o700});locked(path,()=>atomic(path,value));},update:fn=>{mkdirSync(directory,{recursive:true,mode:0o700});return locked(path,()=>{const value=fn(read(path,null));if(value===null){if(existsSync(path))unlinkSync(path);}else atomic(path,value);return value;});},remove:()=>{if(existsSync(path))locked(path,()=>unlinkSync(path));}};
30
- };
31
- export function fromContext({supercodeHome,name,fetch}){const contexts=new WorkspaceContexts(supercodeHome),doc=contexts.read(),selected=name??doc.current;if(!selected||selected==='local')throw new Error('a remote workspace context is required');const context=doc.contexts[selected];if(!context)throw new Error(`unknown context ${selected}`);const credential=contexts.credential(context.credential_ref);if(!credential?.token)throw Object.assign(new Error(`credential ${context.credential_ref} is missing`),{code:'authentication_required'});if(credential.server_id!==context.server_id||credential.origin!==context.origin)throw Object.assign(new Error('credential audience does not match context'),{code:'authentication_required'});return {client:new TeamsClient({server:context.origin,credential:credential.token,fetch}),context};}
32
- /** A context name from a team's name: `Volter` → `volter`. */
33
- export function contextNameOf(team){const name=String(team).toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^[^a-z]+/,'').slice(0,64);return name||'team';}
34
- export async function loginContext({supercodeHome,name,origin,teamId,token,invite,identityToken=null,as,fetch}){if(token!=null&&(invite!=null||identityToken!=null))throw new Error('a token signs in alone');if(token==null&&invite==null&&identityToken==null)throw new Error('a token, an invitation or an identity is required');origin=normalizeOrigin(origin);let credential,serverId,principalId,resolvedTeam=teamId;if(invite){const response=await fetch(new URL('/api/v1/invitations/accept',origin),{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:JSON.stringify({enrollment_code:invite,...(identityToken?{access_token:identityToken}:{})})});if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw new Error('cross-origin redirects are refused');if(!response.ok)throw new Error(`invitation failed: HTTP ${response.status}`);const value=await response.json();credential=value.credential;principalId=value.principal.id;resolvedTeam=value.team.id;token=credential.token;}else if(identityToken){const response=await fetch(new URL('/api/v1/login/volter',origin),{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token:identityToken,...(teamId?{team_id:teamId}:{})})});if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw new Error('cross-origin redirects are refused');if(!response.ok){const detail=await response.json().catch(()=>null);throw new Error(`sign-in failed: ${detail?.error?.message??`HTTP ${response.status}`}`);}const value=await response.json();credential=value.credential;principalId=value.principal.id;resolvedTeam=value.team.id;token=credential.token;}const client=new TeamsClient({server:origin,credential:token,fetch});const me=await client.me();serverId=me.server_id;const membership=me.teams.find(x=>x.team.id===resolvedTeam||x.team.name===resolvedTeam);if(!membership)throw new Error('credential is not a member of the requested team');name??=contextNameOf(membership.team.name);principalId??=me.principal.id;const store=new WorkspaceContexts(supercodeHome);const credentialId=`cred_${crypto.randomUUID().replaceAll('-','')}`;store.writeCredential(credentialId,{token,kind:credential?.kind??'user',expires_at:credential?.expires_at??null,server_id:serverId,origin});const saved=store.upsert(name,{origin,server_id:serverId,team_id:membership.team.id,team_name:membership.team.name,principal_id:principalId,credential_ref:credentialId,label:as??null});if(!store.read().current)store.use(name);return saved;}
1
+ export * from "@volter/teams/context";
@@ -0,0 +1 @@
1
+ export { eventClient } from "@volter/teams/event-client";
package/machine-door.mjs CHANGED
@@ -1,36 +1 @@
1
- // Which door a machine verb goes through: without a machine name, this OS
2
- // user's own daemon over its local socket; with one, the Teams server of the
3
- // selected context, which forwards the channel to that enrolled machine.
4
- import { resolve } from 'node:path';
5
- import { openLocalMachine, openRemoteMachine } from './client.mjs';
6
- import { WorkspaceContexts, fromContext } from './context.mjs';
7
- import { teamsHome, paths } from '@volter/teams/home';
8
-
9
- export async function openMachine({ machine = null, context = null, env = process.env, home = teamsHome(env) } = {}) {
10
- if (!machine) return openLocalMachine(paths(home).socket);
11
- const supercodeHome = env.SUPERCODE_HOME ?? resolve(home, '..');
12
- const contexts = new WorkspaceContexts(supercodeHome);
13
- const name = context ?? env.SUPERCODE_CONTEXT ?? contexts.read().current;
14
- if (!name || name === 'local') throw Object.assign(new Error('a remote machine is reached through a Teams context: `supercode teams login …`, then `supercode context use NAME` or --context NAME'), { code: 'invalid_request' });
15
- const { client, context: selected } = fromContext({ supercodeHome, name, fetch: globalThis.fetch });
16
- const credential = contexts.credential(selected.credential_ref);
17
- let machineId = machine;
18
- if (!machine.startsWith('mach_')) {
19
- const listed = (await client.team(selected.team_id).machines.list()).items;
20
- let matches = listed.filter((item) => item.name === machine);
21
- // A mail address names a machine by its short, lowercased host name
22
- // (`yuerans-macbook-pro`), while enrollment defaults to the full one
23
- // (`Yuerans-MacBook-Pro.local`): fall back to that normal form.
24
- if (!matches.length) matches = listed.filter((item) => normalMachineName(item.name) === normalMachineName(machine));
25
- if (matches.length !== 1) throw Object.assign(new Error(matches.length ? `machine name ${machine} is ambiguous; use its id` : `machine ${machine} is not accessible`), { code: matches.length ? 'ambiguous_reference' : 'not_found' });
26
- machineId = matches[0].id;
27
- }
28
- const channel = await openRemoteMachine({ origin: selected.origin, teamId: selected.team_id, machineId, token: credential.token });
29
- channel.machineName = machine;
30
- return channel;
31
- }
32
-
33
- /** A machine name in the form mail addresses use: the short host name, lowercased, with anything outside [a-z0-9_-] as `-`. */
34
- export function normalMachineName(name) {
35
- return String(name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
36
- }
1
+ export { openMachine, normalMachineName } from "@volter/teams/machine-door";
package/machine.mjs CHANGED
@@ -29,8 +29,12 @@ import { EventEmitter } from 'node:events';
29
29
  import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync, accessSync, constants } from 'node:fs';
30
30
  import { join, resolve, dirname } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
+ import { boardGuardArguments } from './board-process-guard.mjs';
32
33
  import { homedir, hostname } from 'node:os';
33
34
  import { Workspaces, inspectCheckout } from './workspaces.mjs';
35
+ import { WorkspaceContexts, fromContext } from '@volter/teams/context';
36
+ import { TeamsClient } from '@volter/teams/http-client';
37
+ import { supercodeHome } from '@volter/teams/home';
34
38
  import { readMailHistory } from './mail-history.mjs';
35
39
  import { randomBytes, createHash } from 'node:crypto';
36
40
  import { WebSocket } from 'ws';
@@ -39,7 +43,7 @@ import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composer
39
43
  // than failing the load: a POSIX daemon never needs it. Windows needs a supercode-terminal with PtyTerminalHost.
40
44
  import * as terminal from '@volter/supercode-terminal';
41
45
  import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
42
- import { lineSplitter } from './lines.mjs';
46
+ import { lineSplitter } from '@volter/teams/lines';
43
47
  import { NODE_PROTOCOL } from '@volter/teams/channel';
44
48
  import { holds, observesPanes } from '@volter/teams/admission';
45
49
  import { MachineCore, MACHINE_VERSION } from '@volter/teams/machine-core';
@@ -53,6 +57,7 @@ import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from '
53
57
  import { TerminalMailbox, retainedTerminalMail } from './terminal-mail.mjs';
54
58
  import { confirmNativeInitialInput } from './native-initial-input.mjs';
55
59
  import { canonicalCodexFolderTrust, codexProgram, codexStartupArguments, codexUpdatePrompt, codexVersion, settleCodexHookReview } from './codex-startup.mjs';
60
+ import { sessionNoticeTarget } from './session-notice-target.mjs';
56
61
 
57
62
  /** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
58
63
  const PORTHOLE_HOOK = fileURLToPath(new URL('./porthole-hook.cjs', import.meta.url));
@@ -155,6 +160,29 @@ function paneExit(pane) {
155
160
  export class TeamsMachine extends MachineCore {
156
161
  /** Typed lines by key → the answer their first attempt gives (see #serveTurn). */
157
162
  #turnKeys = new Map();
163
+ #sessionBindings = new Map();
164
+
165
+ /** Native process evidence, published by the connector; a missing transport never reports an end. */
166
+ async sessionBindings(machineId,incarnationId) {
167
+ const panes=await this.host.listSessions();
168
+ const live=panes.filter(pane=>!pane.dead&&pane.rootPid);
169
+ const reading=live.length?await this.#serveRequest('harness.v1.sessions.activity_under',{pids:live.map(pane=>pane.rootPid)}):{activities:[]};
170
+ const byPid=new Map((reading.activities??[]).map(item=>[item.pid,item.activity]));
171
+ const current=new Set(panes.filter(pane=>!pane.dead).map(pane=>pane.contextKey));
172
+ for(const pane of live){
173
+ const activity=byPid.get(pane.rootPid);if(!activity?.session_id)continue;
174
+ const key=`${pane.contextKey}:${activity.harness}:${activity.session_id}`;
175
+ this.#sessionBindings.set(key,{id:`pane_${createHash('sha256').update(`${machineId}:${incarnationId}:${key}`).digest('hex').slice(0,32)}`,
176
+ machine_id:machineId,incarnation_id:incarnationId,pane:pane.contextKey,harness:activity.harness,
177
+ native_session_id:activity.session_id,native_runtime_id:activity.session_id,state:activity.presence==='running'?'running':'unknown',
178
+ observed_at:Date.now(),native_capabilities:[],evidence:activity.evidence});
179
+ }
180
+ for(const binding of this.#sessionBindings.values()){
181
+ if(!current.has(binding.pane)){binding.state='stopped';binding.observed_at=Date.now();}
182
+ else if(!live.some(pane=>pane.contextKey===binding.pane&&byPid.get(pane.rootPid)?.session_id===binding.native_session_id)){binding.state='unknown';binding.observed_at=Date.now();}
183
+ }
184
+ return [...this.#sessionBindings.values()];
185
+ }
158
186
 
159
187
  /**
160
188
  * `link`: { server, teamId, machineId, credential, incarnationId, configuration } to hold the
@@ -475,22 +503,87 @@ export class TeamsMachine extends MachineCore {
475
503
 
476
504
  // ---- mail ----------------------------------------------------------------
477
505
 
478
- #serveMail(entry, stream, accept, refuse) {
506
+ #nativeMailReply(request){
507
+ return new Promise((resolve,reject)=>{
508
+ const child=spawn(this.supercodeBin,['message','remote-deliver'],{stdio:['pipe','pipe','ignore'],env:this.env});
509
+ let output='',done=false;
510
+ const finish=(error,result)=>{if(done)return;done=true;clearTimeout(timer);error?reject(error):resolve(result);};
511
+ const timer=setTimeout(()=>{child.kill('SIGTERM');finish(new Error('native mail read timed out'));},10000);
512
+ child.stdout.on('data',chunk=>{output+=chunk;if(output.length>4*1024*1024){child.kill('SIGTERM');finish(new Error('native mail reply exceeded its bound'));}});
513
+ child.once('error',error=>finish(error));
514
+ child.once('close',code=>{
515
+ if(code!==0)return finish(new Error(`native mail read exited ${code}`));
516
+ try{finish(null,JSON.parse(output));}catch{finish(new Error('native mail read returned invalid JSON'));}
517
+ });
518
+ child.stdin.on('error',error=>finish(error));child.stdin.end(JSON.stringify(request));
519
+ });
520
+ }
521
+
522
+ #noticeTarget(address){
523
+ return sessionNoticeTarget(address,{machine:hostname().split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-'),
524
+ mail:request=>this.#nativeMailReply(request),
525
+ discover:params=>this.#serveRequest('harness.v1.sessions.discover',params,10000),
526
+ recordedConfig:locator=>this.#serveRequest('harness.v1.sessions.recorded_config',{locator},15000)});
527
+ }
528
+
529
+ async #resumeNoticeTarget(address){
530
+ const pending=this.noticeResumes??=new Map();
531
+ if(pending.has(address))return pending.get(address);
532
+ const resume=(async()=>{
533
+ const status=await this.#noticeTarget(address);
534
+ if(status.state==='running')return;
535
+ if(!status.resumable)throw Object.assign(new Error(status.reason??'native session cannot resume'),{noticeCode:'session_resume_unavailable'});
536
+ const code=await new Promise((resolve,reject)=>{
537
+ // Explicit native approval and sandbox flags suppress open's unattended defaults.
538
+ const child=spawn(this.supercodeBin,['open',address,'--detach','--',...status.resume.args],
539
+ {cwd:status.resume.cwd,env:{...this.env,SUPERCODE_POPUPS:'0'},stdio:'ignore'});
540
+ const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},15000);
541
+ child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);resolve(code);});
542
+ });
543
+ for(const deadline=Date.now()+10000;;await new Promise(resolve=>setTimeout(resolve,200))){
544
+ const live=await this.#nativeMailReply({op:'list'});
545
+ if(live.code===0&&live.sessions?.some(row=>row.address===address))return;
546
+ if(Date.now()>=deadline)throw Object.assign(new Error(code===0?'native resume started but has no live delivery door':`native resume was not confirmed (exit ${code??'timeout'})`),{noticeCode:'session_resume_pending'});
547
+ }
548
+ })();
549
+ pending.set(address,resume);
550
+ try{return await resume;}finally{pending.delete(address);}
551
+ }
552
+
553
+ #serveMail(entry, stream, accept, refuse,requestOverride=null) {
479
554
  // One message into a session on this machine, delivered by `supercode message
480
555
  // remote-deliver` through the same doors a local send uses. The receiver is the
481
556
  // target: `mail:*` reaches every session here, `mail:<address>` one of them; a
482
557
  // receiver named rather than addressed needs `mail:*`.
483
558
  const { admitted, principal } = entry;
484
- const request = stream.params?.request;
559
+ const request = requestOverride??stream.params?.request;
485
560
  if (request?.op === 'turn') return this.#serveTurn(entry, request, stream, accept, refuse);
486
561
  if (request?.op === 'history') return this.#serveMailHistory(entry, request, stream, accept, refuse);
487
562
  const to = typeof request?.to === 'string' ? request.to : '*';
488
563
  const target = to.startsWith('sc:') ? `mail:${to}` : 'mail:*';
489
- if (!request || !['send', 'file', 'list'].includes(request.op)) return refuse('mail needs a send, file, list or turn request');
564
+ if (!request || !['send', 'file', 'list','status'].includes(request.op)) return refuse('mail needs a send, file, list, status or turn request');
490
565
  if (!holds(admitted, 'mail', target)) {
491
566
  this.log.append({ principal, cap: 'mail', target, outcome: 'refused' });
492
567
  return refuse(`no mail grant for ${target}`);
493
568
  }
569
+ if(request.op==='status'){
570
+ if(!to.startsWith('sc:'))return refuse('status requires one exact native address');
571
+ accept();
572
+ void this.#noticeTarget(to).then(({resume,...native})=>{
573
+ if(!stream.closed){stream.write(JSON.stringify({code:0,native}));stream.close('status');}
574
+ }).catch(()=>{if(!stream.closed){stream.write(JSON.stringify({code:5,native:{state:'unknown',reason:'native_liveness_unavailable',resumable:false}}));stream.close('status');}});
575
+ return;
576
+ }
577
+ if(request.resume_if_closed===true){
578
+ if(request.op!=='send'||!to.startsWith('sc:'))return refuse('thread resume requires an exact send target');
579
+ accept();
580
+ void this.#resumeNoticeTarget(to).then(()=>{
581
+ if(stream.closed)return;
582
+ if(!holds(entry.admitted,'mail',target)){stream.write(JSON.stringify({code:2,error:'access_revoked',text:'native mail grant expired before delivery'}));stream.close('refused');return;}
583
+ this.#serveMail(entry,stream,()=>{},refuseWith=>{if(!stream.closed){stream.write(JSON.stringify({code:2,text:refuseWith}));stream.close('refused');}},{...request,resume_if_closed:false});
584
+ }).catch(error=>{if(!stream.closed){stream.write(JSON.stringify({code:3,error:error.noticeCode??'session_resume_unavailable',text:error.message}));stream.close('refused');}});
585
+ return;
586
+ }
494
587
  accept();
495
588
  const child = spawn(this.supercodeBin, ['message', 'remote-deliver'], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
496
589
  let out = '';
@@ -629,6 +722,8 @@ export class TeamsMachine extends MachineCore {
629
722
  // A long-lived tmux server can predate a consolidated installation.
630
723
  // New panes inherit this daemon's selected tools, not that stale PATH.
631
724
  env.PATH ??= this.env.PATH;
725
+ // Its CLI reads this machine's contexts and sockets, not whatever home that server started with.
726
+ if (this.env.SUPERCODE_HOME) env.SUPERCODE_HOME ??= this.env.SUPERCODE_HOME;
632
727
  const inherited = env.NODE_OPTIONS ?? this.env.NODE_OPTIONS ?? '';
633
728
  // Quoted: NODE_OPTIONS splits on spaces, and a home path may hold one (Windows user folders often do).
634
729
  env.NODE_OPTIONS = `${inherited} --require ${JSON.stringify(PORTHOLE_HOOK)}`.trim();
@@ -640,7 +735,8 @@ export class TeamsMachine extends MachineCore {
640
735
  // Native session hooks carry the launcher's identity across Codex's shared
641
736
  // app-server process boundary. Process ancestry alone cannot recover it.
642
737
  #nativeQuestionCommand(params, pane, by, nativeInput) {
643
- const args = params.arguments ?? [];
738
+ if (params.env?.SUPERCODE_BOARD_PROCESS_GUARD === '1' && process.platform === 'win32') throw new Error('board process guard is unavailable on Windows; no unguarded session was launched');
739
+ const args = boardGuardArguments(params.kind,params.arguments ?? [],{cwd:params.cwd,enabled:params.env?.SUPERCODE_BOARD_PROCESS_GUARD === '1'});
644
740
  const name = String(params.program ?? '').split(/[\\/]/).pop();
645
741
  if (process.platform === 'win32' || !((params.kind === 'codex' && name === 'codex') ||
646
742
  (params.kind === 'claude-code' && name === 'claude'))) return launchCommand(params.program, args);
@@ -1066,12 +1162,14 @@ export class TeamsMachine extends MachineCore {
1066
1162
  v: MACHINE_VERSION,
1067
1163
  id: this.id,
1068
1164
  hostname: hostname(),
1165
+ name: this.machineEvents?.machine ?? hostname(),
1069
1166
  socket: this.socketPath,
1070
1167
  server: this.link?.server ?? null,
1071
1168
  machineId: this.link?.machineId ?? null,
1072
1169
  node: { pid: process.pid, version: MACHINE_VERSION, protocol: NODE_PROTOCOL, floor: this.floor },
1073
1170
  egress: this.env.SUPERCODE_TEAMS_EGRESS ?? 'unrestricted',
1074
1171
  execution: executionCapabilities(this.env),
1172
+ boardProcessPolicy: process.platform === 'win32' ? null : { version: 1, harnesses: ['claude-code','codex'], boundary: 'native-PreToolUse' },
1075
1173
  runSpec: this.env.SUPERCODE_TEAMS_RUN_SPEC_SHA256 ?? null,
1076
1174
  at: Date.now(),
1077
1175
  };
@@ -1430,6 +1528,99 @@ export class TeamsMachine extends MachineCore {
1430
1528
  if (!this.machineEvents || params.team_id!==this.machineEvents.teamId) throw new Error('this connector is not serving that team');
1431
1529
  return method==='machine.events.events'?this.machineEvents.snapshot():this.machineEvents.subscribe(params.address,method.endsWith('.unsubscribe'));
1432
1530
  }
1531
+ case 'harness.v1.agents.bindSession': {
1532
+ if (!operator) throw Object.assign(new Error('binding an agent session requires operator authority'),{code:403});
1533
+ const {session,context}=params;
1534
+ if(typeof session!=='string'||!/^([a-z][a-z0-9-]*):(.+)$/.test(session)||typeof context!=='string')throw Object.assign(new Error('name a native session and its agent reader context'),{code:400});
1535
+ const selected=fromContext({supercodeHome:supercodeHome(this.env),name:context});
1536
+ const identity=await selected.client.team(selected.context.team_id).sessions.get(session).identity({signal:AbortSignal.timeout(15000)});
1537
+ if(!identity.data.agent||identity.data.agent.id!==selected.context.principal_id)throw Object.assign(new Error('session does not belong to the context agent'),{code:403});
1538
+ const file=join(this.home,'agent-session-contexts.json'),record=readJson(file,{version:1,sessions:{}});
1539
+ record.sessions[session]={context,agent:identity.data.agent.id,org:selected.context.team_id,assignments:params.assignments??[],intended:params.intended??null};
1540
+ const temporary=`${file}.${randomBytes(12).toString('hex')}.tmp`;
1541
+ writeFileSync(temporary,JSON.stringify(record)+'\n',{mode:0o600,flag:'wx'});renameSync(temporary,file);
1542
+ act('agents.bindSession',session);return record.sessions[session];
1543
+ }
1544
+ case 'harness.v1.agents.session': {
1545
+ if(!operator)throw Object.assign(new Error('local agent session lookup requires operator authority'),{code:403});
1546
+ if(typeof params.session!=='string')throw Object.assign(new Error('name a native session'),{code:400});
1547
+ return readJson(join(this.home,'agent-session-contexts.json'),{sessions:{}}).sessions[params.session]??null;
1548
+ }
1549
+ case 'harness.v1.agents.closureReceipt': {
1550
+ if(!operator)throw Object.assign(new Error('native closure receipts require operator authority'),{code:403});
1551
+ if(typeof params.closure!=='string'||!/^close_[0-9a-f]{32}$/.test(params.closure))throw Object.assign(new Error('name a retained closure obligation'),{code:400});
1552
+ const receipt=readJson(join(this.home,'agent-closure-receipts.json'),{closures:{}}).closures[params.closure];
1553
+ return {attempted:!!receipt,pending:!!this.agentClosuresRunning?.has(params.closure),result:receipt?.result??null};
1554
+ }
1555
+ case 'harness.v1.agents.closeSession': {
1556
+ if(!operator||!this.link)throw Object.assign(new Error('native agent closure requires the connected machine operator'),{code:403});
1557
+ if(typeof params.closure!=='string'||!/^close_[0-9a-f]{32}$/.test(params.closure))throw Object.assign(new Error('name a retained closure obligation'),{code:400});
1558
+ const {server,teamId,machineId,credential}=this.link;
1559
+ const response=await fetch(`${server}/api/v1/teams/${teamId}/machines/${machineId}/agent-closures/${params.closure}`,{headers:{Authorization:`Bearer ${credential}`},redirect:'manual',signal:AbortSignal.timeout(15000)});
1560
+ if(!response.ok)throw Object.assign(new Error(`agent closure authorization returned HTTP ${response.status}`),{code:403});
1561
+ const closure=(await response.json()).data;
1562
+ if(closure.state!=='claimed'||closure.machine_id!==machineId||closure.team_id!==teamId)throw Object.assign(new Error('the machine has no claimed closure on that session'),{code:403});
1563
+ const session=`${closure.harness}:${closure.native_id}`,binding=readJson(join(this.home,'agent-session-contexts.json'),{sessions:{}}).sessions[session];
1564
+ if(binding&&binding.agent!==closure.agent_id)throw Object.assign(new Error('native session identity changed'),{code:409});
1565
+ const file=join(this.home,'agent-closure-receipts.json'),receipts=readJson(file,{version:1,closures:{}});
1566
+ if(receipts.closures[closure.id])return receipts.closures[closure.id].result??{state:'unknown',reason:'native closure attempt has no retained receipt'};
1567
+ receipts.closures[closure.id]={attempted_at:Date.now()};writeJson(file,receipts);
1568
+ (this.agentClosuresRunning??=new Set()).add(closure.id);
1569
+ let result;
1570
+ try{
1571
+ const found=await this.#serveRequest('harness.v1.sessions.discover',{query:closure.native_id,harnesses:[closure.harness],limit:200});
1572
+ const descriptor=found.sessions?.find(row=>row.locator?.session_id===closure.native_id&&row.locator?.harness===closure.harness);
1573
+ if(descriptor?.live_endpoint?.startsWith('supercode-live://')){
1574
+ const runtime=await this.#serveRequest('harness.v1.runtimes.attach_existing',{harness:closure.harness,runtime_id:closure.native_id,cwd:descriptor.cwd,base_url:descriptor.live_endpoint});
1575
+ const ended=await this.#serveRequest('harness.v1.runtimes.close',{connection:runtime.connection});
1576
+ result=ended?.closed?{state:'closed',reason:'native runtime close receipt'}:{state:'unknown',reason:'runtime returned no close receipt'};
1577
+ }else{
1578
+ const machine=hostname().split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-'),address=`sc:${machine}:${closure.harness}:${closure.native_id}`;
1579
+ const code=await new Promise((resolve,reject)=>{
1580
+ const child=spawn(this.supercodeBin,['close',address],{env:{...this.env,SUPERCODE_CALLER:address},stdio:'ignore'});
1581
+ const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},30000);
1582
+ child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',status=>{clearTimeout(timer);resolve(status);});
1583
+ });
1584
+ result=code===0?{state:'closed',reason:'native session close door completed'}:code===null?{state:'unknown',reason:'native close did not complete within 30 seconds'}:{state:'failed',reason:`native close refused (exit ${code})`};
1585
+ }
1586
+ }catch(error){result={state:'failed',reason:String(error.message).slice(0,1024)};}
1587
+ // Other sessions may have finished while this native close was in flight.
1588
+ try {
1589
+ const retained=readJson(file,{version:1,closures:{}});
1590
+ retained.closures[closure.id]={...retained.closures[closure.id],result};writeJson(file,retained);
1591
+ act('agents.closeSession',session,result.state,result.reason);return result;
1592
+ }finally{this.agentClosuresRunning.delete(closure.id);}
1593
+ }
1594
+ case 'harness.v1.agents.context': {
1595
+ if (!operator) throw Object.assign(new Error('installing an agent reader context requires operator authority'), { code: 403 });
1596
+ const {origin,team,agent,credential}=params;
1597
+ if(typeof origin!=='string'||typeof team!=='string'||typeof agent!=='string'||typeof credential?.token!=='string')throw Object.assign(new Error('agent context requires server, Org, identity and delegated credential'),{code:400});
1598
+ const client=new TeamsClient({server:origin,credential:credential.token});
1599
+ const me=await client.me({signal:AbortSignal.timeout(15000)});
1600
+ if(me.principal.id!==agent||me.principal.kind!=='agent'||!me.teams.some(item=>item.team.id===team))throw Object.assign(new Error('delegated credential does not belong to this agent and Org'),{code:403});
1601
+ const key=createHash('sha256').update(`${client.server}/${team}/${agent}`).digest('hex');
1602
+ const name=`agent-${key.slice(0,32)}`,ref=`cred_${key}`,contexts=new WorkspaceContexts(supercodeHome(this.env));
1603
+ contexts.writeCredential(ref,{token:credential.token,kind:'service',expires_at:credential.expires_at,server_id:me.server_id,origin:client.server});
1604
+ contexts.upsert(name,{origin:client.server,server_id:me.server_id,team_id:team,principal_id:agent,credential_ref:ref,label:agent});
1605
+ act('agents.context',agent);
1606
+ return {context:name,agent,org:team,expires_at:credential.expires_at};
1607
+ }
1608
+ case 'harness.v1.agents.mint': {
1609
+ if (!operator) throw Object.assign(new Error('minting local agent ids requires operator authority'), { code: 403 });
1610
+ if (typeof params.key !== 'string' || !params.key || params.key.length > 1024) throw Object.assign(new Error('agent mint requires a stable key'), { code: 400 });
1611
+ const file = join(this.home, 'agent-identities.json');
1612
+ const record = readJson(file, { version: 1, ids: {} });
1613
+ const key = createHash('sha256').update(params.key).digest('hex');
1614
+ if (!record.ids[key]) {
1615
+ record.ids[key] = { id: `agent_${randomBytes(16).toString('hex')}`, minted_at: Date.now() };
1616
+ const temporary = `${file}.${randomBytes(12).toString('hex')}.tmp`;
1617
+ writeFileSync(temporary, JSON.stringify(record)+'\n', { mode: 0o600, flag: 'wx' });
1618
+ renameSync(temporary,file);
1619
+ }
1620
+ act('agents.mint', key);
1621
+ return { ...record.ids[key], synced: false };
1622
+ }
1623
+ case 'harness.v1.machine.describe':
1433
1624
  case 'describe':
1434
1625
  return this.describe();
1435
1626
  case 'panes.present': {
@@ -1444,6 +1635,7 @@ export class TeamsMachine extends MachineCore {
1444
1635
  return { pane: session.contextKey, idleComposer: composer != null && !/\besc to (?:interrupt|cancel)\b/iu.test(screen) };
1445
1636
  }));
1446
1637
  }
1638
+ case 'harness.v1.panes.list':
1447
1639
  case 'panes.list': {
1448
1640
  // A participant lists the panes its grants let it observe; view on the whole
1449
1641
  // machine (or operator) lists them all.
@@ -1560,12 +1752,14 @@ export class TeamsMachine extends MachineCore {
1560
1752
  if (params.notify) this.#noticeWhenEnded({ key, pane: contextKey, notify: params.notify, label: params.name ?? params.kind ?? params.program });
1561
1753
  return { key, pane: contextKey, reused: false, status: 'ready', window };
1562
1754
  }
1755
+ case 'harness.v1.launches.get':
1563
1756
  case 'launches.get': {
1564
1757
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1565
1758
  const found = this.launches.find(params.key);
1566
1759
  if (!found) return null;
1567
1760
  return this.#launchReceipt(found);
1568
1761
  }
1762
+ case 'harness.v1.launches.cancel':
1569
1763
  case 'launches.cancel': {
1570
1764
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1571
1765
  if (typeof params.key !== 'string' || !params.key.trim()) throw Object.assign(new Error('launch cancellation requires a nonempty key'), { code: 400 });
@@ -1591,6 +1785,7 @@ export class TeamsMachine extends MachineCore {
1591
1785
  }
1592
1786
  return rows;
1593
1787
  }
1788
+ case 'harness.v1.panes.close':
1594
1789
  case 'panes.close': {
1595
1790
  need('control', `pane:${params.pane}`);
1596
1791
  for (const pending of this.pendingStartups.values()) if (pending.pane === params.pane) pending.abort.abort();
@@ -1814,13 +2009,13 @@ export class TeamsMachine extends MachineCore {
1814
2009
  act('title', params.target, 'ok', renamed);
1815
2010
  return { ok: true, target: params.target, title: params.title ?? null, window: renamed };
1816
2011
  }
1817
- case 'workspace.inspect': case 'workspace.register': case 'workspace.unregister': case 'workspace.list': case 'workspace.resolve': {
2012
+ case 'harness.v1.workspaces.inspect': case 'harness.v1.workspaces.register': case 'harness.v1.workspaces.unregister': case 'harness.v1.workspaces.list': case 'harness.v1.workspaces.resolve': {
1818
2013
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1819
2014
  const registry = new Workspaces(this.home);
1820
- const result = method === 'workspace.inspect' ? inspectCheckout(params.path)
1821
- : method === 'workspace.register' ? registry.register(params.path, params.repository)
1822
- : method === 'workspace.unregister' ? registry.unregister(params.path)
1823
- : method === 'workspace.list' ? registry.list()
2015
+ const result = method === 'harness.v1.workspaces.inspect' ? inspectCheckout(params.path)
2016
+ : method === 'harness.v1.workspaces.register' ? registry.register(params.path, params.repository)
2017
+ : method === 'harness.v1.workspaces.unregister' ? registry.unregister(params.path)
2018
+ : method === 'harness.v1.workspaces.list' ? registry.list()
1824
2019
  : registry.resolve(params.repository, params.branch, { solo: params.solo, card: params.card, create: params.create });
1825
2020
  act('workspace', '*', 'ok', method);
1826
2021
  return result;
@@ -20,7 +20,8 @@ const continuing = seed.arguments.includes('resume') || seed.arguments.includes(
20
20
  const writableRoots = [];
21
21
  for (let index = 0; index < seed.arguments.length; index += 1) {
22
22
  const arg = seed.arguments[index];
23
- if (continuing && ['--dangerously-bypass-approvals-and-sandbox', '--yolo'].includes(arg)) {
23
+ if (arg === '--dangerously-bypass-hook-trust') { config.push(arg); }
24
+ else if (continuing && ['--dangerously-bypass-approvals-and-sandbox', '--yolo'].includes(arg)) {
24
25
  config.push('-c', 'approval_policy="never"', '-c', 'sandbox_mode="danger-full-access"');
25
26
  } else if (continuing && arg === '--full-auto') {
26
27
  config.push('-c', 'approval_policy="on-request"', '-c', 'sandbox_mode="workspace-write"');
@@ -41,7 +42,7 @@ for (let index = 0; index < seed.arguments.length; index += 1) {
41
42
  }
42
43
  }
43
44
  if(writableRoots.length)config.push('-c',`sandbox_workspace_write.writable_roots=${JSON.stringify(writableRoots)}`);
44
- const server = spawn(seed.program, ['app-server', '--listen', endpoint, ...config], { stdio: ['ignore', 'ignore', 'inherit'] });
45
+ const server = spawn(seed.program, [...config, 'app-server', '--listen', endpoint], { stdio: ['ignore', 'ignore', 'inherit'] });
45
46
  let tui;
46
47
  let ended = false;
47
48
  let serverError;