@volter/supercode-teams 0.2.113 → 0.2.114

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.
@@ -92,6 +92,11 @@ 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
94
  const SAVE_COALESCE_MS = 2000;
95
+ // The catalog is conversation history, not a model's compacted continuation
96
+ // context. Keep the native artifact separately for continuation/export.
97
+ const captureRead = (harness, options = {}) => harness === 'codex'
98
+ ? { ...options, view: { displayHistory: true, tailMessages: 5000, includeSubagents: false } } : options;
99
+
95
100
 
96
101
  function atomic(path, value) {
97
102
  const temporary = `${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`;
@@ -460,7 +465,7 @@ export class TeamsConnector {
460
465
  this.lastCaptures.set(key,now);
461
466
  const {locator}=options,path=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
462
467
  const artifactBefore=path?statSync(path):null;
463
- options={...options,preloaded:{loaded:await (options.live?this.liveCore:this.core).session(locator).load(),artifactBefore}};
468
+ options={...options,preloaded:{loaded:await (options.live?this.liveCore:this.core).session(locator).load(captureRead(options.harness)),artifactBefore}};
464
469
  }
465
470
  // A capture the server refused and the outbox dropped stays that session's problem; any other capture clears it.
466
471
  const work=this.work.then(()=>this.captureSerial(options)).then(receipt=>{if(!this.droppedCaptures?.delete(`${options.harness}:${options.nativeSessionId}`))this.clearSessionProblem(options.harness,options.nativeSessionId);return receipt;});
@@ -540,7 +545,7 @@ export class TeamsConnector {
540
545
  this.largeCaptures??=new Map();const last=this.largeCaptures.get(key),wait=last&&!this.watched?.has(key)?Math.min(LARGE_CAPTURE_EVERY_MS,Math.max(LARGE_CAPTURE_FLOOR_MS,last.ms*10))-(this.clock.nowMs()-last.at):0;
541
546
  if(wait>0){this.largeRetry??=new Map();if(!this.largeRetry.has(key))this.largeRetry.set(key,setTimeout(()=>{this.largeRetry.delete(key);this.captureLarge(options);},wait).unref?.());continue;}
542
547
  started=this.clock.nowMs();
543
- const loaded=await lane.core.session(options.locator).load({timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)});
548
+ const loaded=await lane.core.session(options.locator).load(captureRead(options.harness,{timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)}));
544
549
  this.largeCaptures.set(key,{at:started,ms:this.clock.nowMs()-started});
545
550
  await this.capture({...options,preloaded:{loaded,artifactBefore}});
546
551
  }catch(error){
@@ -560,10 +565,11 @@ export class TeamsConnector {
560
565
  const artifactSource=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
561
566
  const artifactBefore=preloaded?preloaded.artifactBefore:artifactSource?statSync(artifactSource):null;
562
567
  const large=(artifactBefore?.size??0)>=LARGE_CAPTURE_BYTES;
563
- const loaded=preloaded?preloaded.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;
568
+ const loaded=preloaded?preloaded.loaded:await this.core.session(locator).load(captureRead(harness,large?{timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)}:{})),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
564
569
  const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
565
570
  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};});
566
571
  this.labelTurns(key,loaded.messages??loaded.turns??[],messages);
572
+ if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
567
573
  // A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
568
574
  const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
569
575
  const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
package/machine.mjs CHANGED
@@ -30,6 +30,7 @@ import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readd
30
30
  import { join, resolve, dirname } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { homedir, hostname } from 'node:os';
33
+ import { readMailHistory } from './mail-history.mjs';
33
34
  import { randomBytes, createHash } from 'node:crypto';
34
35
  import { WebSocket } from 'ws';
35
36
  import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
@@ -451,6 +452,7 @@ export class TeamsMachine extends MachineCore {
451
452
  const { admitted, principal } = entry;
452
453
  const request = stream.params?.request;
453
454
  if (request?.op === 'turn') return this.#serveTurn(entry, request, stream, accept, refuse);
455
+ if (request?.op === 'history') return this.#serveMailHistory(entry, request, stream, accept, refuse);
454
456
  const to = typeof request?.to === 'string' ? request.to : '*';
455
457
  const target = to.startsWith('sc:') ? `mail:${to}` : 'mail:*';
456
458
  if (!request || !['send', 'file', 'list'].includes(request.op)) return refuse('mail needs a send, file, list or turn request');
@@ -475,6 +477,24 @@ export class TeamsMachine extends MachineCore {
475
477
  child.stdin.end(JSON.stringify({ ...request, via_principal: principal ?? null }));
476
478
  }
477
479
 
480
+ #serveMailHistory(entry, request, stream, accept, refuse) {
481
+ const { admitted, principal } = entry;
482
+ const granted = (admitted?.grants ?? []).filter(grant => grant.caps.includes('observe')).flatMap(grant => grant.targets).filter(target => target.startsWith('session:') && target !== 'session:*');
483
+ const named = typeof request.harness === 'string' && typeof request.session === 'string' ? `${request.harness}/${request.session}` : granted.length === 1 ? granted[0].slice(8) : '';
484
+ const [harness, session, extra] = named.split('/');
485
+ const target = `session:${named}`;
486
+ if (!harness || !session || extra || !holds(admitted, 'observe', target)) return refuse('no observe grant for this session');
487
+ accept();
488
+ const controller = new AbortController();
489
+ stream.on('close', () => controller.abort());
490
+ void readMailHistory({ binary: this.supercodeBin, env: this.env, harness, session, signal: controller.signal }).then(rows => {
491
+ if (!stream.closed) stream.write(JSON.stringify({ code: 0, rows }));
492
+ this.log.append({ principal, cap: 'observe', target, outcome: 'mail-history' });
493
+ }, error => {
494
+ if (!stream.closed) stream.write(JSON.stringify({ code: 1, text: error.message }));
495
+ }).finally(() => { if (!stream.closed) stream.close('complete'); });
496
+ }
497
+
478
498
  #serveTurn(entry, request, stream, accept, refuse) {
479
499
  // A person's line typed into one session here as that session's own user turn (a Room member in a Conversation
480
500
  // bound to the session): `interact` on `session:<harness>/<id>`. Who typed it comes from the caller's claim
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { hostname } from 'node:os';
4
+ import { spawn } from 'node:child_process';
5
+ import { teamsHome } from '@volter/teams/home';
6
+
7
+ // Match the CLI's local_machine_name: the native mailbox belongs to its
8
+ // enrolled context, which can differ from a viewer connector's --home.
9
+ function machineName(env) {
10
+ let name = hostname();
11
+ try {
12
+ const root = join(teamsHome(env), 'workspaces');
13
+ const contexts = JSON.parse(readFileSync(join(root, 'contexts.json'), 'utf8'));
14
+ const context = contexts.contexts[contexts.current];
15
+ name = JSON.parse(readFileSync(join(root, 'connectors', context.server_id, context.team_id, 'enrollment.json'), 'utf8')).machine.name || name;
16
+ } catch { /* An unenrolled machine's mail uses its hostname. */ }
17
+ return name.split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'localhost';
18
+ }
19
+
20
+ /** Read actual inbound/outbound envelopes. --session is deliberately read-only;
21
+ * sessions.inbox acknowledges mail and must never be used by a viewer. */
22
+ export function readMailHistory({ binary, env, harness, session, signal }) {
23
+ if (!/^[a-z0-9-]+$/.test(harness) || !/^[a-zA-Z0-9_-]+$/.test(session)) throw new Error('invalid session address');
24
+ // A local Teams preview may keep its enrollment separate from the native
25
+ // sessions it displays. This explicitly selects their read-only mail store.
26
+ if (env.SUPERCODE_SESSION_MAIL_HOME) {
27
+ env = { ...env, SUPERCODE_HOME: env.SUPERCODE_SESSION_MAIL_HOME };
28
+ delete env.SUPERCODE_TEAMS_HOME;
29
+ }
30
+ const address = `sc:${machineName(env)}:${harness}:${session}`;
31
+ return new Promise((resolve, reject) => {
32
+ const child = spawn(binary, ['message', 'inbox', '--session', address, '--json', '--recent', '100'], {
33
+ env, stdio: ['ignore', 'pipe', 'pipe'], signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]),
34
+ });
35
+ let out = '', error = '';
36
+ child.stdout.on('data', chunk => { out += chunk; if (out.length > 8_000_000) child.kill(); });
37
+ child.stderr.on('data', chunk => { error = (error + chunk).slice(-1000); });
38
+ child.on('error', reject);
39
+ child.on('close', code => {
40
+ if (code !== 0) return reject(new Error(error || 'Mailbox could not be read'));
41
+ try {
42
+ const rows = JSON.parse(out).filter(row => ['peer', 'channel'].includes(row.envelope?.kind));
43
+ resolve(rows.map(({ envelope, session: to, direction, delivery }) => ({
44
+ id: envelope.id, from: envelope.from, fromName: envelope.from_name, to,
45
+ text: envelope.body, at: envelope.created_at_ms, direction,
46
+ inReplyTo: envelope.in_reply_to ?? null, delivery: delivery?.state ?? 'unknown',
47
+ })));
48
+ } catch (error) { reject(error); }
49
+ });
50
+ });
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.113",
3
+ "version": "0.2.114",
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",