@volter/supercode-teams 0.2.36 → 0.2.38

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.
@@ -60,6 +60,12 @@ const CHUNK_BYTES=1024*1024;
60
60
  // A minute is four beats with no write: the turn is over, or waiting on the user or one long tool call.
61
61
  // Smaller files translate in well under a second and keep getting a bundle with every capture.
62
62
  const NATIVE_IDLE_MS=60000,NATIVE_IDLE_BYTES=8*1024*1024;
63
+ // Loading a transcript reads all of it, and a live one changes on every message: a 135 MB Claude transcript took
64
+ // over 30 s a load on a Windows PC, on every beat, and translating it held the harness server past 272 s while every
65
+ // other request queued behind. A file of LARGE_CAPTURE_BYTES or more is captured at most once per
66
+ // LARGE_CAPTURE_EVERY_MS (tried or not), its load given a second per megabyte, and one past NATIVE_MAX_BYTES is
67
+ // published without a native bundle, which could not be built in time.
68
+ const LARGE_CAPTURE_BYTES=8*1024*1024,LARGE_CAPTURE_EVERY_MS=5*60000,NATIVE_MAX_BYTES=64*1024*1024;
63
69
  const fileStamp=stat=>`${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
64
70
  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;};
65
71
  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;};
@@ -151,7 +157,13 @@ export class TeamsConnector {
151
157
  if (running || this.abort.signal.aborted) return null;
152
158
  running = true;
153
159
  const run = (async () => { this.localRoots = await roots(); await pass(); })();
154
- void run.catch(error => { this.lastError = error; }).finally(() => { running = false; });
160
+ // A pass that fails is said in the connector's log (once per distinct failure): nothing syncs until it passes.
161
+ void run.catch(error => {
162
+ this.lastError = error;
163
+ const said = `${error?.code ?? ''} ${error?.message ?? error}`;
164
+ if (said !== this.lastPassFailure) process.stderr.write(`supercode teams: a sync pass failed: ${error?.message ?? error}\n`);
165
+ this.lastPassFailure = said;
166
+ }).then(() => { if (run && !this.lastError) this.lastPassFailure = null; }).finally(() => { running = false; });
155
167
  return run;
156
168
  };
157
169
  /** The first sync pass, for a caller that waits on it; the connector keeps running if it fails. */
@@ -392,11 +404,13 @@ export class TeamsConnector {
392
404
  if(!configuration)configuration=await this.configuration();const rule=configuration.rules.find(item=>item.id===ruleId&&item.revision===ruleRevision&&item.state==='active');if(!rule)throw Object.assign(new Error('sync rule is absent, inactive, or stale'),{code:'forbidden'});visibility=rule.visibility;
393
405
  const artifactSource=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
394
406
  const artifactBefore=artifactSource?statSync(artifactSource):null;
395
- const loaded=await this.core.session(locator).load(),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
407
+ const large=(artifactBefore?.size??0)>=LARGE_CAPTURE_BYTES,sessionKey=`${harness}:${nativeSessionId}`;
408
+ if(large){this.largeCaptures??=new Map();const last=this.largeCaptures.get(sessionKey);if(last!=null&&this.clock.nowMs()-last<LARGE_CAPTURE_EVERY_MS)return null;this.largeCaptures.set(sessionKey,this.clock.nowMs());}
409
+ const loaded=await this.core.session(locator).load(large?{timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)}:undefined),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
396
410
  const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
397
411
  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};});
398
412
  // A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
399
- const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function';
413
+ const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
400
414
  const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
401
415
  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]);
402
416
  if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
package/machine.mjs CHANGED
@@ -68,6 +68,31 @@ const COMPOSER_RECHECK_MS = 400;
68
68
  * must have stopped changing for this long before the pane counts as ready. */
69
69
  const INITIAL_INPUT_SETTLE_MS = 1_000;
70
70
 
71
+ /**
72
+ * Claude asks whether to trust a folder it has not opened before, whatever its permission mode, and has no flag that
73
+ * skips the question; its default answer exits, taking the pane with it. A Claude this machine launches without
74
+ * prompts has its folder recorded as trusted in this machine's Claude config (`projects[<folder>]` in .claude.json,
75
+ * keyed as Claude keys it: the real path, with forward slashes on Windows), as a Codex launch has its trust passed on
76
+ * its command line. A whole new file is renamed over the old one, since Claude rewrites it while it runs.
77
+ */
78
+ function trustClaudeFolder(cwd) {
79
+ try {
80
+ const config = join(process.env.CLAUDE_CONFIG_DIR || homedir(), '.claude.json');
81
+ const real = realpathSync(cwd);
82
+ const folder = process.platform === 'win32' ? real.replace(/^\\\\\?\\/, '').replaceAll('\\', '/') : real;
83
+ let root = {};
84
+ try { root = JSON.parse(readFileSync(config, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') return; }
85
+ if (!root || typeof root !== 'object' || Array.isArray(root)) return;
86
+ const projects = root.projects ??= {};
87
+ const project = projects[folder] ??= {};
88
+ if (project.hasTrustDialogAccepted === true) return;
89
+ project.hasTrustDialogAccepted = true;
90
+ const staged = `${config}.supercode-${process.pid}`;
91
+ writeFileSync(staged, JSON.stringify(root, null, 2));
92
+ renameSync(staged, config);
93
+ } catch { /* Claude asks as it always did */ }
94
+ }
95
+
71
96
  /** How long a launch watches its new pane before answering, so a program that ends at once is reported. */
72
97
  const LAUNCH_SETTLE_MS = 500;
73
98
 
@@ -353,9 +378,10 @@ export class TeamsMachine extends MachineCore {
353
378
  // receiver named rather than addressed needs `mail:*`.
354
379
  const { admitted, principal } = entry;
355
380
  const request = stream.params?.request;
381
+ if (request?.op === 'turn') return this.#serveTurn(entry, request, stream, accept, refuse);
356
382
  const to = typeof request?.to === 'string' ? request.to : '*';
357
383
  const target = to.startsWith('sc:') ? `mail:${to}` : 'mail:*';
358
- if (!request || !['send', 'file', 'list'].includes(request.op)) return refuse('mail needs a send, file or list request');
384
+ if (!request || !['send', 'file', 'list'].includes(request.op)) return refuse('mail needs a send, file, list or turn request');
359
385
  if (!holds(admitted, 'mail', target)) {
360
386
  this.log.append({ principal, cap: 'mail', target, outcome: 'refused' });
361
387
  return refuse(`no mail grant for ${target}`);
@@ -377,6 +403,37 @@ export class TeamsMachine extends MachineCore {
377
403
  child.stdin.end(JSON.stringify({ ...request, via_principal: principal ?? null }));
378
404
  }
379
405
 
406
+ #serveTurn(entry, request, stream, accept, refuse) {
407
+ // A person's line typed into one session here as that session's own user turn (a Room member in a Conversation
408
+ // bound to the session): `interact` on `session:<harness>/<id>`. Who typed it comes from the caller's claim
409
+ // (the name its workplace signed), never from the request.
410
+ const { admitted, principal } = entry;
411
+ // A caller the server admitted for one session (a Room member's token) names nothing: its grant names the session.
412
+ const granted = admitted?.class === 'operator' ? [] : (admitted?.grants ?? []).filter((grant) => grant.caps.includes('interact')).flatMap((grant) => grant.targets).filter((target) => target.startsWith('session:') && target !== 'session:*');
413
+ const named = typeof request.harness === 'string' && typeof request.session === 'string' ? `${request.harness}/${request.session}` : (granted.length === 1 ? granted[0].slice('session:'.length) : '');
414
+ const [harness, session] = named.includes('/') ? [named.slice(0, named.indexOf('/')), named.slice(named.indexOf('/') + 1)] : ['', ''];
415
+ const target = `session:${harness}/${session}`;
416
+ if (!harness || !session || typeof request.text !== 'string' || !request.text.trim()) return refuse('a turn needs harness, session and text');
417
+ if (!holds(admitted, 'interact', target)) {
418
+ this.log.append({ principal, cap: 'interact', target, outcome: 'refused' });
419
+ return refuse(`no interact grant for ${target}`);
420
+ }
421
+ accept();
422
+ const child = spawn(this.supercodeBin, ['message', 'remote-deliver'], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
423
+ let out = '';
424
+ let err = '';
425
+ child.stdout.on('data', (chunk) => { out += chunk; });
426
+ child.stderr.on('data', (chunk) => { err += chunk; });
427
+ child.on('error', (error) => { err += error.message; });
428
+ child.on('close', (code) => {
429
+ this.log.append({ principal, cap: 'interact', target, outcome: code === 0 ? 'turn' : 'failed', detail: err.trim().slice(0, 200) || undefined });
430
+ if (stream.closed) return;
431
+ stream.write(out.trim() || JSON.stringify({ code: 1, text: err.trim() || `remote-deliver exited ${code}` }));
432
+ stream.close('delivered');
433
+ });
434
+ child.stdin.end(JSON.stringify({ op: 'turn', harness, session, text: request.text, from_name: entry.name ?? principal ?? 'a Room member', via: 'room' }));
435
+ }
436
+
380
437
  // ---- terminal ------------------------------------------------------------
381
438
 
382
439
  async #attachTerminal(entry, stream, accept, refuse) {
@@ -989,6 +1046,7 @@ export class TeamsMachine extends MachineCore {
989
1046
  }
990
1047
  const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
991
1048
  this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'issued' });
1049
+ if (params.kind === 'claude-code' && params.cwd && (params.arguments ?? []).includes('--dangerously-skip-permissions')) trustClaudeFolder(params.cwd);
992
1050
  let session;
993
1051
  try {
994
1052
  session = await this.host.createSession({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
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",