@volter/supercode-teams 0.3.18 → 0.3.20
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/calendar.mjs +1 -1
- package/bin/workspace.mjs +10 -2
- package/connector/index.mjs +86 -19
- package/connector/redactor.mjs +67 -0
- package/machine.mjs +17 -0
- package/package.json +2 -2
- package/workspaces.mjs +2 -16
|
@@ -24,7 +24,7 @@ const at = (value, name) => { const t = Date.parse(String(value ?? '')); if (!Nu
|
|
|
24
24
|
|
|
25
25
|
function readBoard(root) {
|
|
26
26
|
if (!root) usage('--root names the board\'s home');
|
|
27
|
-
const text = execFileSync(supercode(), ['workflow', 'load', '--
|
|
27
|
+
const text = execFileSync(supercode(), ['workflow', 'load', '--json', String(root)], { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
28
28
|
const board = Object.values(JSON.parse(text).workflow.boards)[0];
|
|
29
29
|
return { tasks: Object.entries(board.tasks).map(([id, task]) => ({ id, ...task })), dependencies: board.dependencies ?? [] };
|
|
30
30
|
}
|
package/bin/workspace.mjs
CHANGED
|
@@ -1063,8 +1063,16 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
1063
1063
|
else if (event.event === 'dropped') linkDownSince ??= Date.now();
|
|
1064
1064
|
else if (event.event === 'replaced') linkReplaced = true;
|
|
1065
1065
|
});
|
|
1066
|
-
// The connector is the service's daemon
|
|
1067
|
-
|
|
1066
|
+
// The connector is the service's daemon; a daemon that took its socket path is the one to stop. Run by a service,
|
|
1067
|
+
// the connector stops a linkless one itself and restarts onto the socket: a session's supercode older than the
|
|
1068
|
+
// wait for the service starts one whenever the socket does not answer, and this machine sat unlinked behind it.
|
|
1069
|
+
machine.on('superseded', (path) => {
|
|
1070
|
+
// A holder with a server link is another connector for this machine, which its `replaced` link says.
|
|
1071
|
+
let linked = true; try { linked = Boolean(JSON.parse(readFileSync(paths(daemonHome).machine, 'utf8')).server); } catch { /* no record */ }
|
|
1072
|
+
if (!saved.service || linked) { process.stderr.write(`supercode teams: another machine daemon replaced this connector's socket at ${path}; stop it (supercode teams status)\n`); return; }
|
|
1073
|
+
process.stderr.write(`supercode teams: another machine daemon took this connector's socket at ${path}; restarting onto it\n`);
|
|
1074
|
+
void (async () => { await retireLinklessDaemon(paths(daemonHome).machine); await stopConnector?.(); process.exit(75); })();
|
|
1075
|
+
});
|
|
1068
1076
|
const stopAll = async () => { machineEvents.stop(); await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await largeCore.close(); await slowCore.close(); await liveCore.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
|
|
1069
1077
|
// A daemon with no server link holding the socket is a session's auto-start while this service was between
|
|
1070
1078
|
// restarts (from a supercode old enough not to wait for the service): it yields to the service's connector, which
|
package/connector/index.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, realpathSync, statSync, openSync, closeSync, fsyncSync, unlinkSync, readSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { redactionBasis } from '@volter/teams/redact';
|
|
3
|
+
import { Redactor } from './redactor.mjs';
|
|
2
4
|
import { basename, dirname, join } from 'node:path';
|
|
3
5
|
import { randomBytes, createHash } from 'node:crypto';
|
|
4
6
|
import { canonicalJson, carryDigest } from '../contracts/index.mjs';
|
|
@@ -46,7 +48,7 @@ const trailingToolResults=messages=>{
|
|
|
46
48
|
// The kept-back digests after such a settled rewrite, or null when the kept-back history changed in any other way.
|
|
47
49
|
// A baseline recorded before its tool tail was (baseline_tool_tail absent) takes the tail from the current messages.
|
|
48
50
|
function settledToolTail(previous,messages){
|
|
49
|
-
const baseline=previous.baseline,count=baseline.length
|
|
51
|
+
const baseline=previous.baseline,count=baseline.length;
|
|
50
52
|
if(messages.length<count||baseline.some((native_key,index)=>messages[index].native_key!==native_key))return null;
|
|
51
53
|
const tail=previous.baseline_tool_tail??trailingToolResults(messages.slice(0,count));
|
|
52
54
|
if(!tail||!Number.isSafeInteger(tail.from)||tail.from<1||tail.from+tail.call_ids.length!==count)return null;
|
|
@@ -57,12 +59,23 @@ function settledToolTail(previous,messages){
|
|
|
57
59
|
return messages.slice(0,count).map(digestOf);
|
|
58
60
|
}
|
|
59
61
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
62
|
+
// What leaves for the server is redacted (@volter/teams/redact), and every digest this connector keeps or sends (what it
|
|
63
|
+
// observed, a baseline, the carried prefix) is of that form: a digest of the local text named a prefix the server never
|
|
64
|
+
// held, every carry was refused, and each session holding a secret went whole on every pass. A message is redacted once
|
|
65
|
+
// per process, in the connector's redaction worker: by its digest, nothing kept for one without a secret (most), its
|
|
66
|
+
// redacted form for one with. Each loaded message's digest (of the form sent) is taken once and read where needed.
|
|
67
|
+
const REDACTED_MEMO=200000;
|
|
68
|
+
const redactions=new Map();
|
|
69
|
+
const sentDigests=new WeakMap();
|
|
70
|
+
const digestOf=message=>{let digest=sentDigests.get(message);if(digest===undefined){digest=hash(canonicalJson(message.value));sentDigests.set(message,digest);}return digest;};
|
|
71
|
+
const remember=(digest,redacted)=>{if(redactions.size>=REDACTED_MEMO)redactions.clear();redactions.set(digest,redacted);};
|
|
60
72
|
// A capture part is one request, and the server refuses a body over 8 MiB (volter-teams request-json.mjs): a part
|
|
61
73
|
// holds at most PART_MESSAGES messages (the contract's bound) and PART_BYTES of them, so a run of large tool results
|
|
62
74
|
// splits across parts instead of failing the whole session. A single message larger than that goes alone.
|
|
63
75
|
const PART_MESSAGES=500,PART_BYTES=6*1024*1024;
|
|
64
76
|
function captureParts(captureId,messages){
|
|
65
77
|
const parts=[];let current=[],bytes=0;
|
|
78
|
+
// The messages are already in the form sent (sentValue, applied as a session is loaded).
|
|
66
79
|
for(const message of messages){
|
|
67
80
|
const size=Buffer.byteLength(JSON.stringify(message));
|
|
68
81
|
if(current.length&&(current.length>=PART_MESSAGES||bytes+size>PART_BYTES)){parts.push({capture_id:captureId,index:parts.length,messages:current});current=[];bytes=0;}
|
|
@@ -298,10 +311,18 @@ export class TeamsConnector {
|
|
|
298
311
|
let captured = true;
|
|
299
312
|
// A session whose store is unchanged since this rule last committed it is in sync, whatever it last failed.
|
|
300
313
|
const changed = this.sessionChanged(options); if (!changed) this.clearSessionProblem(harness, nativeSessionId);
|
|
301
|
-
|
|
314
|
+
// A store this process may not read (macOS denies a launchd job a removable volume, whatever the owner granted
|
|
315
|
+
// the terminal) is said once with its path and skipped: asking again each pass only costs time every readable
|
|
316
|
+
// session is waiting for.
|
|
317
|
+
const unreadable = this.unreadable?.get(`${harness}:${nativeSessionId}`);
|
|
318
|
+
if (unreadable) captured = false;
|
|
319
|
+
else if (changed && !this.predatesWindow(options)) try { await this.capture(options); } catch (error) {
|
|
302
320
|
if (connectionError(error) || this.abort.signal.aborted) throw error;
|
|
303
321
|
captured = false; this.noteSessionProblem(options, error);
|
|
304
|
-
|
|
322
|
+
if (/Operation not permitted|os error 1\b|EPERM|EACCES|Permission denied/.test(String(error.message))) {
|
|
323
|
+
(this.unreadable ??= new Map()).set(`${harness}:${nativeSessionId}`, options.locator?.storage?.path ?? null);
|
|
324
|
+
process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} is skipped: this process may not read its store${options.locator?.storage?.path ? ` (${options.locator.storage.path})` : ''}: ${error.message}\n`);
|
|
325
|
+
} else process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
|
|
305
326
|
}
|
|
306
327
|
try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
|
|
307
328
|
// A large transcript is never followed: its harness process would hold the whole session and compare and copy
|
|
@@ -389,7 +410,7 @@ export class TeamsConnector {
|
|
|
389
410
|
const known=this.liveFiles?.get(key);
|
|
390
411
|
if(!known||this.liveFileCaptures.has(key))continue;
|
|
391
412
|
const options={...known};
|
|
392
|
-
if(
|
|
413
|
+
if(this.unreadable?.has(key)||!this.sessionChanged(options)||this.liveFileStamps.get(key)===options.stamp)continue;
|
|
393
414
|
this.liveFileStamps.set(key,options.stamp);this.liveFileCaptures.add(key);
|
|
394
415
|
// Re-read authorization when a live file changes; never keep a stale rule
|
|
395
416
|
// alive merely because somebody still has the transcript open.
|
|
@@ -538,11 +559,45 @@ export class TeamsConnector {
|
|
|
538
559
|
this.save();this.pruneSpill();
|
|
539
560
|
}
|
|
540
561
|
// A translated bundle, spilled, in the form the cache keeps: one entry per file, primary first or not.
|
|
541
|
-
|
|
562
|
+
// Each loaded message in the form sent: the memo's; the loaded one itself when the server holds it unchanged under
|
|
563
|
+
// this redaction basis (its digest is among those last observed); else what the redaction worker makes of it, all of
|
|
564
|
+
// a capture's new messages in one request. A digest is taken once per message, of the form sent.
|
|
565
|
+
async sentMessages(raw,previous){
|
|
566
|
+
const basis=redactionBasis(),held=previous?.redaction_basis===basis&&previous?.observed_digests?.length?new Set(previous.observed_digests):null;
|
|
567
|
+
const out=new Array(raw.length),missing=[];
|
|
568
|
+
raw.forEach((message,index)=>{
|
|
569
|
+
const text=canonicalJson(message.value),digest=hash(text);
|
|
570
|
+
if(redactions.has(digest)){const kept=redactions.get(digest);out[index]=kept?{...message,value:kept.value}:message;sentDigests.set(out[index],kept?kept.digest:digest);return;}
|
|
571
|
+
if(held?.has(digest)){remember(digest,null);out[index]=message;sentDigests.set(message,digest);return;}
|
|
572
|
+
missing.push([index,digest,text]);
|
|
573
|
+
});
|
|
574
|
+
if(missing.length){
|
|
575
|
+
this.redactor??=new Redactor();
|
|
576
|
+
if(!this.redactionCounted){this.redactionCounted=true;this.syncStatus().redaction={since:new Date(this.clock.nowMs()).toISOString(),batches:0,messages:0,bytes:0,worker_ms:0,main_ms:0,max_main_ms:0};}
|
|
577
|
+
// The worker is sent each new message's canonical JSON and answers only those redaction changed.
|
|
578
|
+
let started=performance.now();const texts=missing.map(([,,text])=>text),pending=this.redactor.run('canonical',texts);let mainMs=performance.now()-started;
|
|
579
|
+
const changed=await pending;started=performance.now();
|
|
580
|
+
for(const [index,digest] of missing){remember(digest,null);out[index]=raw[index];sentDigests.set(raw[index],digest);}
|
|
581
|
+
for(const {index:at,value,digest:sent} of changed){
|
|
582
|
+
const [index,digest]=missing[at];
|
|
583
|
+
remember(digest,{value,digest:sent});out[index]={...raw[index],value};sentDigests.set(out[index],sent);
|
|
584
|
+
}
|
|
585
|
+
mainMs+=performance.now()-started;
|
|
586
|
+
// What redaction costs this connector, kept beside its sync status: the worker's busy time, and this thread's time
|
|
587
|
+
// handing new text over and taking the answer (its longest single stretch is the most a door waited on it).
|
|
588
|
+
// Since this process's worker started.
|
|
589
|
+
const cost=this.syncStatus().redaction;
|
|
590
|
+
cost.batches+=1;cost.messages+=missing.length;cost.bytes+=texts.reduce((n,text)=>n+text.length,0);
|
|
591
|
+
cost.worker_ms=Math.round(this.redactor.workerMs);cost.main_ms=Math.round((cost.main_ms+mainMs)*10)/10;cost.max_main_ms=Math.max(cost.max_main_ms,Math.round(mainMs*10)/10);
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
async spillTranslated(translated,defaultName){
|
|
542
596
|
const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
|
|
543
597
|
if(files.filter(file=>file.role==='primary').length!==1)throw Object.assign(new Error('native codec bundle must contain exactly one primary file'),{code:'fidelity_unavailable'});
|
|
544
598
|
let total=0;const spilled=[];
|
|
545
|
-
|
|
599
|
+
this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content));
|
|
600
|
+
for(const [index,file] of files.entries()){const bytes=Buffer.from(texts[index],'utf8');total+=bytes.length;if(total>256*1024*1024)throw Object.assign(new Error('native artifact bundle exceeds 256 MiB'),{code:'request_too_large'});spilled.push({path:file.path,role:file.role,...this.spill(bytes)});}
|
|
546
601
|
return {fidelity:translated.fidelity,residue:translated.residue??[],files:spilled};
|
|
547
602
|
}
|
|
548
603
|
// The outbox artifacts and native_bundle of one capture from a spilled bundle; every capture gets fresh upload ids.
|
|
@@ -560,6 +615,10 @@ export class TeamsConnector {
|
|
|
560
615
|
// upload; a followed (live) session loads through a harness process of its own, so a watched session's new turn
|
|
561
616
|
// never waits behind a pass's loads.
|
|
562
617
|
async capture(options) {
|
|
618
|
+
// A session the server refused for size is set aside until its sync rule changes: loading and sending it again
|
|
619
|
+
// could only be refused again, and two such sessions held a core on every pass. It stays a session problem.
|
|
620
|
+
const aside=this.state.set_aside?.[`${options.harness}:${options.nativeSessionId}`];
|
|
621
|
+
if(aside&&aside.rule_revision===(options.ruleRevision??null))return null;
|
|
563
622
|
if(!options.preloaded&&this.largeSession(options))return this.captureLarge(options);
|
|
564
623
|
if(!options.preloaded){
|
|
565
624
|
const key=`${options.harness}:${options.nativeSessionId}`,now=this.clock.nowMs();
|
|
@@ -636,7 +695,7 @@ export class TeamsConnector {
|
|
|
636
695
|
const kept=previous.baseline&&!clearBaseline?new Set(previous.baseline):null;
|
|
637
696
|
const held=previous.observed_keys.map((key,index)=>[key,previous.observed_digests?.[index]]).filter(([key])=>!kept?.has(key));
|
|
638
697
|
const limit=Math.min(messages.length-1,held.length);let count=0;
|
|
639
|
-
while(count<limit&&messages[count].native_key===held[count][0]&&
|
|
698
|
+
while(count<limit&&messages[count].native_key===held[count][0]&&digestOf(messages[count])===held[count][1])count++;
|
|
640
699
|
return count>=CARRY_MIN_MESSAGES?count:0;
|
|
641
700
|
}
|
|
642
701
|
largeSession({locator}){
|
|
@@ -716,23 +775,24 @@ export class TeamsConnector {
|
|
|
716
775
|
this.save();
|
|
717
776
|
}
|
|
718
777
|
}
|
|
719
|
-
|
|
720
|
-
this.labelTurns(key,loaded.messages??loaded.turns??[],
|
|
778
|
+
// The turns typed through a door here are labelled before redaction, which then reads them as sent.
|
|
779
|
+
const raw=(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};});this.labelTurns(key,loaded.messages??loaded.turns??[],raw);
|
|
780
|
+
let messages=await this.sentMessages(raw,previous);const loadedMessages=messages,basis=redactionBasis();
|
|
721
781
|
if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
|
|
722
782
|
// A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
|
|
723
783
|
const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
|
|
724
784
|
const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
|
|
725
|
-
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]&&
|
|
785
|
+
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]&&digestOf(message)===previous.observed_digests?.[index]);
|
|
726
786
|
if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
|
|
727
787
|
let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
|
|
728
788
|
// A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
|
|
729
789
|
// whatever a session holds when first seen: a session begun after the rule is published from its first message.
|
|
730
790
|
const keptBefore=backfillPolicy.mode==='since'?backfillPolicy.since:backfillPolicy.mode==='none'&&Number.isSafeInteger(rule.capture_after)?rule.capture_after:null;
|
|
731
|
-
if(!previous&&backfillPolicy.mode==='none'&&keptBefore===null){const toolTail=trailingToolResults(messages);this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>
|
|
791
|
+
if(!previous&&backfillPolicy.mode==='none'&&keptBefore===null){const toolTail=trailingToolResults(messages);this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>digestOf(x)),...(toolTail?{baseline_tool_tail:toolTail}:{}),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>digestOf(x)),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
732
792
|
if(!previous&&(backfillPolicy.mode==='since'||backfillPolicy.mode==='none')){
|
|
733
793
|
if(!Number.isSafeInteger(keptBefore))throw Object.assign(new Error('since backfill requires an integer timestamp'),{code:'invalid_request'});
|
|
734
794
|
const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<keptBefore);
|
|
735
|
-
effectivePrevious={generation:1,sequence:0,digest:null,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>
|
|
795
|
+
effectivePrevious={generation:1,sequence:0,digest:null,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>digestOf(message))};
|
|
736
796
|
this.state.origins[key]=effectivePrevious;
|
|
737
797
|
}
|
|
738
798
|
// A source keyed by position (a Claude Code transcript is) can change a message it already reported: the core
|
|
@@ -744,13 +804,13 @@ export class TeamsConnector {
|
|
|
744
804
|
// timestamps; a `none` rule cannot tell old history from new, so that session waits for an explicit
|
|
745
805
|
// `supercode teams sync resume RULE --backfill all` (or a since window), which republishes it whole.
|
|
746
806
|
if(!clearBaseline&&effectivePrevious?.baseline?.length&&effectivePrevious.baseline.every(x=>/^\d+$/.test(x))){
|
|
747
|
-
let baselineMoved=effectivePrevious.baseline.some((native_key,index)=>messages[index]?.native_key!==native_key||
|
|
807
|
+
let baselineMoved=effectivePrevious.baseline.some((native_key,index)=>messages[index]?.native_key!==native_key||digestOf(messages[index])!==effectivePrevious.baseline_digests?.[index]);
|
|
748
808
|
// A rule that started mid tool call kept back the core's placeholder result; the real result replaces it.
|
|
749
809
|
const settled=baselineMoved&&backfillPolicy.mode==='none'?settledToolTail(effectivePrevious,messages):null;
|
|
750
810
|
if(settled){baselineMoved=false;effectivePrevious={...effectivePrevious,baseline_digests:settled};this.state.origins[key]={...this.state.origins[key],baseline_digests:settled};this.save();}
|
|
751
811
|
if(baselineMoved&&Number.isSafeInteger(keptBefore)){
|
|
752
812
|
const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<keptBefore);
|
|
753
|
-
effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>
|
|
813
|
+
effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>digestOf(message))};
|
|
754
814
|
// A tool tail recorded for the old baseline describes positions that no longer hold; it goes with it.
|
|
755
815
|
delete effectivePrevious.baseline_tool_tail;
|
|
756
816
|
this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};delete this.state.origins[key].baseline_tool_tail;
|
|
@@ -791,7 +851,7 @@ export class TeamsConnector {
|
|
|
791
851
|
else try{
|
|
792
852
|
const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
|
|
793
853
|
if(fileStamp(statSync(artifactSource))===onDisk){
|
|
794
|
-
bundle={stamp:onDisk,...this.spillTranslated(translated,basename(artifactSource))};
|
|
854
|
+
bundle={stamp:onDisk,...await this.spillTranslated(translated,basename(artifactSource))};
|
|
795
855
|
// Only a translation that took real time is worth keeping; a small file translates again in no time.
|
|
796
856
|
if(artifactBefore.size>=this.nativeIdleBytes){this.state.native_cache[key]=bundle;this.save();this.pruneSpill();}
|
|
797
857
|
}
|
|
@@ -810,11 +870,11 @@ export class TeamsConnector {
|
|
|
810
870
|
}else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
|
|
811
871
|
const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
|
|
812
872
|
const digest=entries=>hash(canonicalJson((entries.messages??entries.turns??[]).map(entry=>entry.value??entry)));if(digest(await this.core.session(locator).load())!==digest(loaded))throw Object.assign(new Error('native session changed while it was being captured'),{code:'resync_required'});
|
|
813
|
-
({artifacts,nativeBundle}=this.bundleArtifacts(this.spillTranslated(translated,`${nativeSessionId}.json`),harness));
|
|
873
|
+
({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`),harness));
|
|
814
874
|
}
|
|
815
875
|
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}:{})};
|
|
816
876
|
const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
|
|
817
|
-
this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:
|
|
877
|
+
this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:loadedMessages.map(message=>message.native_key),digests:loadedMessages.map(digestOf)},redactionBasis:basis});this.save();const receipt=await this.flushSerial();await this.publishSessionProfile(key);return receipt;
|
|
818
878
|
}
|
|
819
879
|
async publishSessionProfile(session){
|
|
820
880
|
const observation=this.state.profile_outbox?.find(item=>item.session===session);if(!observation)return;
|
|
@@ -936,7 +996,14 @@ export class TeamsConnector {
|
|
|
936
996
|
if(!Array.isArray(configuration?.rules))return false;
|
|
937
997
|
return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
|
|
938
998
|
}
|
|
939
|
-
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===
|
|
999
|
+
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===409&&error.code==='batch_gap'&&error.details&&Number.isSafeInteger(error.details.sequence)){
|
|
1000
|
+
// The server holds a different last capture of this session than this connector recorded (a capture it
|
|
1001
|
+
// committed while the connector stopped before recording it, for one): this connector takes the server's
|
|
1002
|
+
// revision, drops the stale capture and sends the session whole on its next pass.
|
|
1003
|
+
const key=`${item.begin.harness}:${item.begin.native_session_id}`,expected=error.details;this.lastError=error;
|
|
1004
|
+
process.stderr.write(`supercode teams: ${item.begin.harness} session ${item.begin.native_session_id} resumes at the server's revision ${expected.generation}/${expected.sequence}; it goes whole next time\n`);
|
|
1005
|
+
this.state.outbox.shift();this.state.origins[key]={...this.state.origins[key],generation:expected.generation,sequence:expected.sequence-1,digest:expected.digest??null,observed_keys:[],observed_digests:[],carry_refused:true};this.save();continue;}
|
|
1006
|
+
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}`);if(error.status===413)(this.state.set_aside??={})[`${item.begin.harness}:${item.begin.native_session_id}`]={at:this.clock.nowMs(),rule_revision:item.begin.rule_revision??null,reason:error.message};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,redaction_basis:item.redactionBasis??null,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.
|
|
940
1007
|
this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
|
|
941
|
-
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();}
|
|
1008
|
+
stop(){void this.redactor?.close();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();}
|
|
942
1009
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Redaction runs in a worker thread of its own: the connector's thread also serves this machine's socket and its link
|
|
2
|
+
// to the Teams server, and a capture's redaction (all of a session's new text through gitleaks' rules) held both for
|
|
3
|
+
// seconds at a time. One request per capture: the messages it found new, or the files of a native bundle.
|
|
4
|
+
import { Worker, isMainThread, parentPort } from 'node:worker_threads';
|
|
5
|
+
|
|
6
|
+
if (!isMainThread && parentPort) {
|
|
7
|
+
const { redactText, redactValue } = await import('@volter/teams/redact');
|
|
8
|
+
const { canonicalJson } = await import('../contracts/index.mjs');
|
|
9
|
+
const { createHash } = await import('node:crypto');
|
|
10
|
+
// 'canonical': each item is a message value's canonical JSON (the connector has it already, for its digest), a string
|
|
11
|
+
// copied to this thread for little more than its bytes, where a value's object graph costs the connector's thread a
|
|
12
|
+
// walk each way. Only what redaction changed comes back, with its new digest.
|
|
13
|
+
const changedOf = (items) => {
|
|
14
|
+
const changed = [];
|
|
15
|
+
items.forEach((text, index) => {
|
|
16
|
+
const value = redactValue(JSON.parse(text)), sent = canonicalJson(value);
|
|
17
|
+
if (sent !== text) changed.push({ index, value, digest: createHash('sha256').update(sent).digest('hex') });
|
|
18
|
+
});
|
|
19
|
+
return changed;
|
|
20
|
+
};
|
|
21
|
+
const { performance } = await import('node:perf_hooks');
|
|
22
|
+
parentPort.on('message', ({ id, kind, items }) => {
|
|
23
|
+
// Its own busy time rides back with each answer: what redaction costs, apart from the connector's other work.
|
|
24
|
+
const started = performance.now();
|
|
25
|
+
try { const answer = kind === 'canonical' ? changedOf(items) : items.map((item) => (kind === 'texts' ? redactText(item) : redactValue(item))); parentPort.postMessage({ id, items: answer, busy: performance.now() - started }); }
|
|
26
|
+
catch (error) { parentPort.postMessage({ id, error: String(error?.message ?? error) }); }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Redactor {
|
|
31
|
+
constructor() { this.worker = null; this.next = 0; this.waiting = new Map(); this.workerMs = 0; }
|
|
32
|
+
|
|
33
|
+
start() {
|
|
34
|
+
// The connector's own node flags are not the worker's (one meant for the entry script refuses to start it).
|
|
35
|
+
const worker = new Worker(new URL(import.meta.url), { execArgv: [] });
|
|
36
|
+
worker.on('message', ({ id, items, error, busy }) => {
|
|
37
|
+
const waiter = this.waiting.get(id); if (!waiter) return;
|
|
38
|
+
this.waiting.delete(id);
|
|
39
|
+
this.workerMs += busy ?? 0;
|
|
40
|
+
if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: 'redaction_failed' }));
|
|
41
|
+
else waiter.resolve(items);
|
|
42
|
+
if (!this.waiting.size) worker.unref();
|
|
43
|
+
});
|
|
44
|
+
// A worker that dies fails what it held (nothing leaves unredacted); the next request starts another.
|
|
45
|
+
const fail = (error) => {
|
|
46
|
+
if (this.worker === worker) this.worker = null;
|
|
47
|
+
for (const waiter of this.waiting.values()) waiter.reject(Object.assign(new Error(`redaction failed: ${error?.message ?? error}`), { code: 'redaction_failed' }));
|
|
48
|
+
this.waiting.clear();
|
|
49
|
+
};
|
|
50
|
+
worker.on('error', fail);
|
|
51
|
+
worker.on('exit', (code) => fail(`its worker exited (${code})`));
|
|
52
|
+
worker.unref();
|
|
53
|
+
this.worker = worker;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `items` redacted, in order: `kind` 'values' (JSON values, every string in them) or 'texts'; for 'canonical' (canonical
|
|
57
|
+
* JSON texts) only the changed ones, as `{ index, value, digest }`. */
|
|
58
|
+
run(kind, items) {
|
|
59
|
+
if (!items.length) return Promise.resolve([]);
|
|
60
|
+
if (!this.worker) this.start();
|
|
61
|
+
const id = this.next++;
|
|
62
|
+
this.worker.ref();
|
|
63
|
+
return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items }); });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
close() { const worker = this.worker; this.worker = null; return worker ? worker.terminate().then(() => {}) : Promise.resolve(); }
|
|
67
|
+
}
|
package/machine.mjs
CHANGED
|
@@ -72,6 +72,16 @@ const INITIAL_INPUT_READY_DEADLINE_MS = 90_000;
|
|
|
72
72
|
const INITIAL_INPUT_POLL_MS = 250;
|
|
73
73
|
/** The pause between the two composer readings a user's turn waits on. */
|
|
74
74
|
const COMPOSER_RECHECK_MS = 400;
|
|
75
|
+
// Claude Code waits out a usage limit and queues every message typed meanwhile until the limit's reset ("new messages
|
|
76
|
+
// wait for your usage limit to reset"; esc or /rate-limit-options ends the wait). A limit raised early, or a weekly
|
|
77
|
+
// one past 24 hours, never ends that wait, so a session sat with every turn it was sent queued behind it. Read from the
|
|
78
|
+
// bottom of the screen, where the wait is shown, never from the conversation above it.
|
|
79
|
+
export function claudeUsageLimitWait(screen) {
|
|
80
|
+
const rows = String(screen).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').split('\n').map((line) => line.trim()).filter(Boolean);
|
|
81
|
+
const status = rows.slice(-6).join('\n');
|
|
82
|
+
// the wait's own banner on the status rows, never words in the conversation, and never a turn that is running
|
|
83
|
+
return /usage limit to reset/i.test(status) && /rate-limit-options/i.test(status) && !/esc to interrupt/i.test(status);
|
|
84
|
+
}
|
|
75
85
|
|
|
76
86
|
/** The settle floor for a pane whose kind has no composer pattern: the screen
|
|
77
87
|
* must have stopped changing for this long before the pane counts as ready. */
|
|
@@ -2035,6 +2045,13 @@ export class TeamsMachine extends MachineCore {
|
|
|
2035
2045
|
if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
|
|
2036
2046
|
const kind = this.#paneKind(params.pane, session);
|
|
2037
2047
|
if (kind === 'claude-code') {
|
|
2048
|
+
// A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
|
|
2049
|
+
// still holds refuses the turn at once and the session waits again, for the next turn to try.
|
|
2050
|
+
const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
|
|
2051
|
+
if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
|
|
2052
|
+
await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
|
|
2053
|
+
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
2054
|
+
}
|
|
2038
2055
|
const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
|
|
2039
2056
|
const first = await draftOf();
|
|
2040
2057
|
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
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.
|
|
69
|
+
"@volter/teams": "0.2.11",
|
|
70
70
|
"@xterm/addon-serialize": "^0.14.0",
|
|
71
71
|
"@xterm/headless": "^6.0.0",
|
|
72
72
|
"protobufjs": "8.8.0",
|
package/workspaces.mjs
CHANGED
|
@@ -4,27 +4,13 @@ import { join, dirname, resolve } from 'node:path';
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
export { teamsHome } from '@volter/teams/home';
|
|
6
6
|
|
|
7
|
+
import { inspectCheckout, repositoryId } from '@volter/teams/checkout';
|
|
7
8
|
const git = (path, args, optional = false) => {
|
|
8
9
|
const r = spawnSync('git', ['-C', path, ...args], { encoding: 'utf8', timeout: 30_000 });
|
|
9
10
|
if (r.status !== 0) { if (optional) return null; throw new Error(`git ${args[0]} in ${path}: ${(r.stderr || r.error?.message || 'failed').trim()}`); }
|
|
10
11
|
return r.stdout.trim();
|
|
11
12
|
};
|
|
12
|
-
export
|
|
13
|
-
let s = String(value ?? '').trim().replace(/\.git\/?$/, '').replace(/\/$/, '');
|
|
14
|
-
if (/^[^/@]+@[^:]+:/.test(s)) s = s.replace(/^[^@]+@/, '').replace(':', '/');
|
|
15
|
-
else if (/^[a-z]+:\/\//i.test(s)) { const u = new URL(s); s = u.host + u.pathname; }
|
|
16
|
-
s = s.replace(/^github\.com\//i, '');
|
|
17
|
-
if (!/^[\w.-]+(?:\/[\w.-]+)+$/.test(s) || s.split('/').some(p => p === '.' || p === '..')) throw new Error('repository must be a git remote identity, for example volter-ai/editor; not a machine path');
|
|
18
|
-
return s;
|
|
19
|
-
}
|
|
20
|
-
export function inspectCheckout(path) {
|
|
21
|
-
const raw = git(path, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir']);
|
|
22
|
-
const [root, dir, common] = raw.split('\n').map(p => realpathSync(resolve(path, p)));
|
|
23
|
-
// The repository the checkout declares (remote.origin.url), not where a URL rewrite (insteadOf: a mirror, a proxy, a
|
|
24
|
-
// World's twin) sends its transport.
|
|
25
|
-
const remote = git(root, ['config', '--get', 'remote.origin.url'], true);
|
|
26
|
-
return { path: realpathSync(path), root, linked: dir !== common, repository: remote ? repositoryId(remote) : null, branch: git(root, ['branch', '--show-current']) || null };
|
|
27
|
-
}
|
|
13
|
+
export { inspectCheckout, repositoryId };
|
|
28
14
|
export class Workspaces {
|
|
29
15
|
constructor(home) { this.file = join(home, 'workspaces.jsonl'); }
|
|
30
16
|
// Every registration stands until its checkout is registered again or unregistered; the newest standing row
|