@volter/supercode-teams 0.3.19 → 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.
@@ -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', '--from', 'hermes', '--json', String(root)], { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
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 and keeps serving; a daemon that took its socket path is the one to stop.
1067
- machine.on('superseded', (path) => process.stderr.write(`supercode teams: another machine daemon replaced this connector's socket at ${path}; stop it (supercode teams status)\n`));
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
@@ -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,digestOf=message=>hash(canonicalJson(message.value));
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
- if (changed && !this.predatesWindow(options)) try { await this.capture(options); } catch (error) {
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
- process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
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(!this.sessionChanged(options)||this.liveFileStamps.get(key)===options.stamp)continue;
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
- spillTranslated(translated,defaultName){
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
- for(const file of files){const bytes=Buffer.from(file.content,'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)});}
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]&&hash(canonicalJson(messages[count].value))===held[count][1])count++;
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
- 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};});
720
- this.labelTurns(key,loaded.messages??loaded.turns??[],messages);
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]&&hash(canonicalJson(message.value))===previous.observed_digests?.[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=>hash(canonicalJson(x.value))),...(toolTail?{baseline_tool_tail:toolTail}:{}),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>hash(canonicalJson(x.value))),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
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=>hash(canonicalJson(message.value)))};
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||hash(canonicalJson(messages[index].value))!==effectivePrevious.baseline_digests?.[index]);
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=>hash(canonicalJson(message.value)))};
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:(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;
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===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.
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.19",
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.10",
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 function repositoryId(value) {
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