@volter/supercode-teams 0.2.88 → 0.2.90

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.
@@ -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(){atomic(this.statePath,this.state);}
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.88",
3
+ "version": "0.2.90",
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",
@@ -4,10 +4,10 @@
4
4
  // daemon's ConPTY host on Windows, holds it); the pane ending ends the client, and the terminal closes the window as
5
5
  // its command exits. Adapters are tried in rank order; SUPERCODE_POPUP_TERMINAL names one (or `none`).
6
6
  import { spawn, spawnSync } from 'node:child_process';
7
- import { chmodSync, existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
8
- import { homedir, tmpdir } from 'node:os';
7
+ import { chmodSync, existsSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
- import { createHash, randomBytes } from 'node:crypto';
10
+ import { randomBytes } from 'node:crypto';
11
11
  import { weztermConfigFile } from './terminal-look.mjs';
12
12
 
13
13
  const onPath = (name) => {
@@ -27,39 +27,13 @@ function selfDeletingScript(argv, extension) {
27
27
  return path;
28
28
  }
29
29
 
30
- // Supercode's windows share one WezTerm process, as Terminal.app's windows share one app: a window opened into a
31
- // running process is on screen in about 0.3 s, where a process started per window took 0.7 to 2 s (measured on macOS
32
- // 26 with WezTerm 20240203). The process is known by its class; it listens on ~/.local/share/wezterm/gui-sock-<pid>.
33
- // A window goes into it with `wezterm cli spawn --new-window`, and when there is none, or the spawn fails, the window
34
- // starts it. The fallback runs in the launched shell, so the daemon never waits on WezTerm. The class carries the
35
- // config's content: WezTerm does not reload it (terminal-look.mjs), so a process holding an older look is never reused.
36
- const WEZTERM_CLASS = 'dev.volter.supercode';
37
-
38
- /** The socket of a running supercode WezTerm process of class `cls`, or null. */
39
- function weztermSocket(cls) {
40
- const dir = join(homedir(), '.local', 'share', 'wezterm');
41
- let names;
42
- try { names = readdirSync(dir); } catch { return null; }
43
- for (const name of names) {
44
- const pid = Number(/^gui-sock-(\d+)$/.exec(name)?.[1]);
45
- if (!pid) continue;
46
- try { process.kill(pid, 0); } catch { continue; }
47
- const command = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 2_000 }).stdout ?? '';
48
- if (command.includes(`--class ${cls} `)) return join(dir, name);
49
- }
50
- return null;
51
- }
52
-
53
- /** WezTerm's launch: a window in supercode's running WezTerm, else a new one with supercode's look (terminal-look.mjs). */
30
+ // Each window is a WezTerm process of its own, with supercode's look (terminal-look.mjs). Windows sharing one process
31
+ // share its one main thread: on the owner's Mac, four Claude sessions' output kept that thread painting for whole
32
+ // seconds at a time (97-98% of 3 s samples), and a drag in any other popup went unanswered meanwhile. A process of its
33
+ // own opens in 0.7 to 2 s where a window in a running process opened in about 0.3 s; it is never frozen by another
34
+ // window's output.
54
35
  function weztermLaunch(program, argv) {
55
- const config = weztermConfigFile();
56
- const cls = `${WEZTERM_CLASS}.${createHash('sha256').update(readFileSync(config)).digest('hex').slice(0, 12)}`;
57
- const start = [program, '--config-file', config, 'start', '--always-new-process', '--class', cls, '--', ...argv];
58
- const socket = process.platform === 'win32' ? null : weztermSocket(cls);
59
- if (!socket) return [start[0], start.slice(1)];
60
- const quote = (arg) => `'${String(arg).replaceAll("'", `'"'"'`)}'`;
61
- const spawnWindow = [program, 'cli', '--no-auto-start', 'spawn', '--new-window', '--', ...argv].map(quote).join(' ');
62
- return ['/bin/sh', ['-c', `WEZTERM_UNIX_SOCKET=${quote(socket)} ${spawnWindow} >/dev/null 2>&1 || exec ${start.map(quote).join(' ')}`]];
36
+ return [program, ['--config-file', weztermConfigFile(), 'start', '--always-new-process', '--', ...argv]];
63
37
  }
64
38
 
65
39
  /** Adapters by platform, in rank order: { name, find() → program or null, launch(program, argv) → [command, args] }. */