@volter/supercode-teams 0.2.89 → 0.2.91
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/connector/index.mjs +9 -2
- package/machine.mjs +18 -2
- package/package.json +1 -1
package/connector/index.mjs
CHANGED
|
@@ -91,6 +91,8 @@ const messageText=value=>typeof value?.content==='string'?value.content.trim():A
|
|
|
91
91
|
const fileStamp=stat=>`${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
92
92
|
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;};
|
|
93
93
|
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;};
|
|
94
|
+
const SAVE_COALESCE_MS = 2000;
|
|
95
|
+
|
|
94
96
|
function atomic(path, value) {
|
|
95
97
|
const temporary = `${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`;
|
|
96
98
|
const fd = openSync(temporary, 'wx', 0o600);
|
|
@@ -108,7 +110,12 @@ function atomic(path, value) {
|
|
|
108
110
|
}
|
|
109
111
|
export class TeamsConnector {
|
|
110
112
|
constructor({server,teamId,machineId,sourceId,credential,stateDir,core,largeCore=core,slowCore=largeCore,liveCore=core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch,heartbeatMs=15000,nativeIdleMs=NATIVE_IDLE_MS,nativeIdleBytes=NATIVE_IDLE_BYTES}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,largeCore,slowCore,liveCore,hostKeyId,effectExecutor,clock,fetch:fetchImpl,heartbeatMs,nativeIdleMs,nativeIdleBytes});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};if(Array.isArray(this.state.sync_status?.problems))this.state.sync_status.problems=this.state.sync_status.problems.filter(item=>item?.code!==CAPACITY_CODE);this.state.native_cache??={};this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
|
|
111
|
-
save
|
|
113
|
+
// A capture burst saves after every step, and each save is a synchronous stringify, write and fsync of the whole
|
|
114
|
+
// state (megabytes once many sessions are tracked) on the loop that also answers the machine's doors: a Room line
|
|
115
|
+
// typed during a burst on a loaded disk waited out the server's 15 s open bound behind them. A burst writes once per
|
|
116
|
+
// window instead; stop() writes what is pending, so a clean stop loses nothing.
|
|
117
|
+
save(){this.saveDirty=true;if(this.saveTimer)return;this.saveTimer=setTimeout(()=>{this.saveTimer=null;try{this.flushState();}catch(error){process.stderr.write(`supercode teams: could not save the connector's state: ${error.message}\n`);}},SAVE_COALESCE_MS);this.saveTimer.unref?.();}
|
|
118
|
+
flushState(){if(this.saveTimer){clearTimeout(this.saveTimer);this.saveTimer=null;}if(!this.saveDirty)return;this.saveDirty=false;atomic(this.statePath,this.state);}
|
|
112
119
|
// What `supercode teams status` shows about this connector: its last error, and the sessions it could not capture
|
|
113
120
|
// (most recent first, bounded; an entry clears when that session next captures). They are kept in connector.json,
|
|
114
121
|
// so another process reads them while this one runs; the error itself stays in memory as lastError.
|
|
@@ -720,5 +727,5 @@ export class TeamsConnector {
|
|
|
720
727
|
}
|
|
721
728
|
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.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.
|
|
722
729
|
this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
|
|
723
|
-
stop(){if(this.timer)clearInterval(this.timer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
|
|
730
|
+
stop(){try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
|
|
724
731
|
}
|
package/machine.mjs
CHANGED
|
@@ -96,6 +96,8 @@ function trustClaudeFolder(cwd) {
|
|
|
96
96
|
|
|
97
97
|
/** How long a launch watches its new pane before answering, so a program that ends at once is reported. */
|
|
98
98
|
const LAUNCH_SETTLE_MS = 500;
|
|
99
|
+
/** How many typed lines' keys a machine remembers; a server retries a line for minutes, not thousands of lines. */
|
|
100
|
+
const TURN_KEYS_KEPT = 1000;
|
|
99
101
|
|
|
100
102
|
/**
|
|
101
103
|
* What a pane runs for a launch: the program with its arguments, or, when the caller named a whole command line
|
|
@@ -139,6 +141,9 @@ function paneExit(pane) {
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
export class TeamsMachine extends MachineCore {
|
|
144
|
+
/** Typed lines by key → the answer their first attempt gives (see #serveTurn). */
|
|
145
|
+
#turnKeys = new Map();
|
|
146
|
+
|
|
142
147
|
/**
|
|
143
148
|
* `link`: { server, teamId, machineId, credential, incarnationId, configuration } to hold the
|
|
144
149
|
* server link; `configuration()` resolves the machine's current configuration from the server
|
|
@@ -485,6 +490,17 @@ export class TeamsMachine extends MachineCore {
|
|
|
485
490
|
// The user-turn door every supercode has (harness.v1 sessions.message, as_user): the session's runtime input,
|
|
486
491
|
// its pane's empty composer, or its mailbox until the composer is free. The sender is named after the member.
|
|
487
492
|
const answer = (value) => { if (!stream.closed) { stream.write(JSON.stringify(value)); stream.close('delivered'); } };
|
|
493
|
+
// A keyed line is typed once: the server retries a line whose open outlived its bound, and the attempt it gave up
|
|
494
|
+
// on may still be accepted here. A repeat of a key answers what its first attempt answers.
|
|
495
|
+
const turnKey = typeof request.turn_key === 'string' && request.turn_key.length <= 128 ? `${target}\u0000${request.turn_key}` : null;
|
|
496
|
+
const seen = turnKey ? this.#turnKeys.get(turnKey) : null;
|
|
497
|
+
if (seen) { void seen.then(answer); return; }
|
|
498
|
+
let settle;
|
|
499
|
+
if (turnKey) {
|
|
500
|
+
this.#turnKeys.set(turnKey, new Promise((resolve) => { settle = resolve; }));
|
|
501
|
+
if (this.#turnKeys.size > TURN_KEYS_KEPT) this.#turnKeys.delete(this.#turnKeys.keys().next().value);
|
|
502
|
+
}
|
|
503
|
+
const reply = (value) => { settle?.(value); answer(value); };
|
|
488
504
|
const at = Date.now();
|
|
489
505
|
this.#serveRequest('harness.v1.sessions.message', {
|
|
490
506
|
locator: { harness, session_id: session, storage: { kind: 'file', path: '' } },
|
|
@@ -495,12 +511,12 @@ export class TeamsMachine extends MachineCore {
|
|
|
495
511
|
const delivered = result?.message_id && !result.refusal;
|
|
496
512
|
this.log.append({ principal, cap: 'interact', target, outcome: delivered ? 'turn' : 'failed', detail: result?.refusal?.message?.slice(0, 200) });
|
|
497
513
|
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 */ }
|
|
498
|
-
|
|
514
|
+
reply(delivered
|
|
499
515
|
? { code: 0, turn_id: result.message_id, door: result.delivery?.how === 'waiting' ? 'waiting' : (result.delivery?.door ?? 'pane') }
|
|
500
516
|
: { code: 1, text: result?.refusal?.message ?? 'the session did not take the turn' });
|
|
501
517
|
}, (error) => {
|
|
502
518
|
this.log.append({ principal, cap: 'interact', target, outcome: 'failed', detail: error.message.slice(0, 200) });
|
|
503
|
-
|
|
519
|
+
reply({ code: 1, text: error.message });
|
|
504
520
|
});
|
|
505
521
|
}
|
|
506
522
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.91",
|
|
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",
|