@volter/supercode-teams 0.2.42 → 0.2.44
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 +1 -1
- package/connector/index.mjs +56 -4
- package/machine.mjs +5 -1
- package/package.json +1 -1
package/bin/workspace.mjs
CHANGED
|
@@ -722,7 +722,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
722
722
|
const hostKeyId = publicKeyId(loadOrCreateKeyPair(paths(daemonHome).machineKey).publicKeyPem);
|
|
723
723
|
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, hostKeyId });
|
|
724
724
|
// A connected machine is a person's: each pane it launches opens a window on its screen (SUPERCODE_POPUPS=0: none).
|
|
725
|
-
const machine = new TeamsMachine({ home: daemonHome, supercode: binary, popups: process.env.SUPERCODE_POPUPS !== '0', ...(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() } });
|
|
725
|
+
const machine = new TeamsMachine({ home: daemonHome, supercode: binary, popups: process.env.SUPERCODE_POPUPS !== '0', ...(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) });
|
|
726
726
|
machine.on('link', (event) => { if (event.event === 'replaced') process.stderr.write(`supercode teams: ${event.error}. Two connectors were running for this machine; stop one of them (\`supercode teams disconnect\` where it should not run).\n`); });
|
|
727
727
|
const stopAll = async () => { await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
|
|
728
728
|
try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
|
package/connector/index.mjs
CHANGED
|
@@ -66,6 +66,9 @@ const NATIVE_IDLE_MS=60000,NATIVE_IDLE_BYTES=8*1024*1024;
|
|
|
66
66
|
// LARGE_CAPTURE_EVERY_MS (tried or not), its load given a second per megabyte, and one past NATIVE_MAX_BYTES is
|
|
67
67
|
// published without a native bundle, which could not be built in time.
|
|
68
68
|
const LARGE_CAPTURE_BYTES=8*1024*1024,LARGE_CAPTURE_EVERY_MS=5*60000,NATIVE_MAX_BYTES=64*1024*1024;
|
|
69
|
+
// A turn a Room member typed here waits this long to be found in its session's transcript.
|
|
70
|
+
const TURN_NOTE_MS=24*3600000;
|
|
71
|
+
const messageText=value=>typeof value?.content==='string'?value.content.trim():Array.isArray(value?.content)?value.content.map(part=>typeof part==='string'?part:part?.type==='text'?part.text??'':'').join('').trim():'';
|
|
69
72
|
const fileStamp=stat=>`${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
70
73
|
const recordedAt=entry=>{if(Number.isSafeInteger(entry.recorded_at))return entry.recorded_at;if(Number.isSafeInteger(entry.timestamp))return entry.timestamp;const value=entry.metadata?.timestamp??entry.value?.metadata?.timestamp??entry.timestamp;if(typeof value!=='string')return null;const parsed=Date.parse(value);return Number.isSafeInteger(parsed)?parsed:null;};
|
|
71
74
|
const serverOrigin=value=>{const url=new URL(value),loopback=url.hostname==='localhost'||url.hostname==='::1'||url.hostname==='[::1]'||/^127(?:\.\d{1,3}){3}$/.test(url.hostname);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||(url.pathname!==''&&url.pathname!=='/')||(url.protocol==='http:'&&!loopback))throw new TypeError('server must be an HTTPS origin (HTTP is loopback-only)');return url.origin;};
|
|
@@ -401,22 +404,71 @@ export class TeamsConnector {
|
|
|
401
404
|
this.save();
|
|
402
405
|
}
|
|
403
406
|
capture(options) {
|
|
407
|
+
if(!options.preloaded&&this.largeSession(options))return Promise.resolve(this.captureLarge(options));
|
|
404
408
|
const work=this.work.then(()=>this.captureSerial(options)).then(receipt=>{this.clearSessionProblem(options.harness,options.nativeSessionId);return receipt;});
|
|
405
409
|
this.work=work.catch(()=>{});
|
|
406
410
|
return work;
|
|
407
411
|
}
|
|
408
|
-
|
|
412
|
+
// Who typed a user turn that came through a door here (a Room member through the session channel): the daemon
|
|
413
|
+
// notes it as it types the turn, and every capture carries it as `source` on the user message holding that text.
|
|
414
|
+
noteTurn({harness,session,text,at,source}){
|
|
415
|
+
const key=`${harness}:${session}`,now=this.clock.nowMs(),notes=this.state.turn_notes??={};
|
|
416
|
+
notes[key]=[...(notes[key]??[]).filter(note=>now-note.at<TURN_NOTE_MS),{text:String(text).trim(),at,source}];
|
|
417
|
+
this.save();
|
|
418
|
+
}
|
|
419
|
+
labelTurns(key,entries,messages){
|
|
420
|
+
const labels={...this.state.turn_labels?.[key]},notes=[...(this.state.turn_notes?.[key]??[])];let found=false;
|
|
421
|
+
for(const note of [...notes]){
|
|
422
|
+
const match=messages.find(message=>!labels[message.native_key]&&message.value?.role==='user'&&(message.recorded_at==null||message.recorded_at>=note.at-60000)&&messageText(message.value)===note.text);
|
|
423
|
+
if(match){labels[match.native_key]=note.source;notes.splice(notes.indexOf(note),1);found=true;}
|
|
424
|
+
}
|
|
425
|
+
if(found){(this.state.turn_labels??={})[key]=labels;if(notes.length)this.state.turn_notes[key]=notes;else delete this.state.turn_notes[key];this.save();}
|
|
426
|
+
messages.forEach((message,index)=>{
|
|
427
|
+
const source=labels[message.native_key];if(!source)return;
|
|
428
|
+
message.value={...message.value,source};
|
|
429
|
+
const entry=entries[index];if(entry.value)entry.value={...entry.value,source};else entry.source=source;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
largeSession({locator}){
|
|
433
|
+
try{return locator?.storage?.kind==='file'&&statSync(realpathSync(locator.storage.path)).size>=LARGE_CAPTURE_BYTES;}catch{return false;}
|
|
434
|
+
}
|
|
435
|
+
// A large transcript's load takes seconds to minutes. It runs in a lane of its own, outside the serial capture
|
|
436
|
+
// queue, so no small session's capture waits behind it; only the capture of what it loaded joins the queue.
|
|
437
|
+
captureLarge(options){
|
|
438
|
+
(this.largeQueue??=new Map()).set(`${options.harness}:${options.nativeSessionId}`,options);
|
|
439
|
+
if(!this.largeLane)this.largeLane=this.runLargeLane().finally(()=>{this.largeLane=null;});
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
async runLargeLane(){
|
|
443
|
+
while(this.largeQueue.size&&!this.abort.signal.aborted){
|
|
444
|
+
const [key,options]=this.largeQueue.entries().next().value;this.largeQueue.delete(key);
|
|
445
|
+
try{
|
|
446
|
+
const artifactBefore=statSync(realpathSync(options.locator.storage.path));
|
|
447
|
+
this.largeCaptures??=new Map();const last=this.largeCaptures.get(key);
|
|
448
|
+
if(last!=null&&this.clock.nowMs()-last<LARGE_CAPTURE_EVERY_MS)continue;
|
|
449
|
+
this.largeCaptures.set(key,this.clock.nowMs());
|
|
450
|
+
const loaded=await this.core.session(options.locator).load({timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)});
|
|
451
|
+
await this.capture({...options,preloaded:{loaded,artifactBefore}});
|
|
452
|
+
}catch(error){
|
|
453
|
+
if(this.abort.signal.aborted)return;
|
|
454
|
+
this.noteSessionProblem(options,error);
|
|
455
|
+
process.stderr.write(`supercode teams: ${options.harness} session ${options.nativeSessionId} not synced: ${error.message}\n`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async captureSerial({locator,descriptor=null,harness,nativeSessionId,visibility='restricted',ruleId=null,ruleRevision=null,coverage={completeness:'complete',reasons:[],missing:[]},backfill='all',rootPath=null,configuration=null,stamp,preloaded=null}) {
|
|
409
460
|
if(this.state.outbox.length)await this.flushSerial();
|
|
410
461
|
// Discovery hands each session the configuration its beat fetched; a follow event fetches its own.
|
|
411
462
|
if(stamp===undefined)stamp=this.sessionStamp({locator,descriptor});
|
|
412
463
|
if(!configuration)configuration=await this.configuration();const rule=configuration.rules.find(item=>item.id===ruleId&&item.revision===ruleRevision&&item.state==='active');if(!rule)throw Object.assign(new Error('sync rule is absent, inactive, or stale'),{code:'forbidden'});visibility=rule.visibility;
|
|
413
464
|
const artifactSource=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
|
|
414
|
-
const artifactBefore=artifactSource?statSync(artifactSource):null;
|
|
465
|
+
const artifactBefore=preloaded?preloaded.artifactBefore:artifactSource?statSync(artifactSource):null;
|
|
415
466
|
const large=(artifactBefore?.size??0)>=LARGE_CAPTURE_BYTES,sessionKey=`${harness}:${nativeSessionId}`;
|
|
416
|
-
if(large){this.largeCaptures??=new Map();const last=this.largeCaptures.get(sessionKey);if(last!=null&&this.clock.nowMs()-last<LARGE_CAPTURE_EVERY_MS)return null;this.largeCaptures.set(sessionKey,this.clock.nowMs());}
|
|
417
|
-
const loaded=await this.core.session(locator).load(large?{timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)}:undefined),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
|
|
467
|
+
if(large&&!preloaded){this.largeCaptures??=new Map();const last=this.largeCaptures.get(sessionKey);if(last!=null&&this.clock.nowMs()-last<LARGE_CAPTURE_EVERY_MS)return null;this.largeCaptures.set(sessionKey,this.clock.nowMs());}
|
|
468
|
+
const loaded=preloaded?preloaded.loaded:await this.core.session(locator).load(large?{timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)}:undefined),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
|
|
418
469
|
const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
|
|
419
470
|
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};});
|
|
471
|
+
this.labelTurns(key,loaded.messages??loaded.turns??[],messages);
|
|
420
472
|
// A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
|
|
421
473
|
const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
|
|
422
474
|
const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
|
package/machine.mjs
CHANGED
|
@@ -135,9 +135,11 @@ export class TeamsMachine extends MachineCore {
|
|
|
135
135
|
* server link; `configuration()` resolves the machine's current configuration from the server
|
|
136
136
|
* (custodian and machine grants). Without it the daemon serves its local door only.
|
|
137
137
|
*/
|
|
138
|
-
constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null, popups = false } = {}) {
|
|
138
|
+
constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null, popups = false, onTurn = null } = {}) {
|
|
139
139
|
super({ home, floor, limits, env, link });
|
|
140
140
|
this.supercodeBin = supercode;
|
|
141
|
+
// Told of each turn a Room member typed here (the connector labels the captured message with who typed it).
|
|
142
|
+
this.onTurn = onTurn;
|
|
141
143
|
this.tmuxSocket = tmuxSocket ?? null;
|
|
142
144
|
// Each pane launched here opens a terminal window on this machine's screen (popupWindow).
|
|
143
145
|
this.popups = Boolean(popups);
|
|
@@ -422,6 +424,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
422
424
|
// The user-turn door every supercode has (harness.v1 sessions.message, as_user): the session's runtime input,
|
|
423
425
|
// its pane's empty composer, or its mailbox until the composer is free. The sender is named after the member.
|
|
424
426
|
const answer = (value) => { if (!stream.closed) { stream.write(JSON.stringify(value)); stream.close('delivered'); } };
|
|
427
|
+
const at = Date.now();
|
|
425
428
|
this.#serveRequest('harness.v1.sessions.message', {
|
|
426
429
|
locator: { harness, session_id: session, storage: { kind: 'file', path: '' } },
|
|
427
430
|
text: request.text,
|
|
@@ -430,6 +433,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
430
433
|
}, 15_000).then((result) => {
|
|
431
434
|
const delivered = result?.message_id && !result.refusal;
|
|
432
435
|
this.log.append({ principal, cap: 'interact', target, outcome: delivered ? 'turn' : 'failed', detail: result?.refusal?.message?.slice(0, 200) });
|
|
436
|
+
if (delivered) try { this.onTurn?.({ harness, session, text: request.text, at, source: { via: 'room', from: { principalId: principal ?? null, name: entry.name ?? null } } }); } catch { /* the turn is delivered either way */ }
|
|
433
437
|
answer(delivered
|
|
434
438
|
? { code: 0, turn_id: result.message_id, door: result.delivery?.how === 'waiting' ? 'waiting' : (result.delivery?.door ?? 'pane') }
|
|
435
439
|
: { code: 1, text: result?.refusal?.message ?? 'the session did not take the turn' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.44",
|
|
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",
|