@volter/supercode-teams 0.2.112 → 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.
- package/bin/teams.mjs +1 -1
- package/connector/index.mjs +9 -3
- package/machine.mjs +24 -5
- package/mail-history.mjs +51 -0
- package/package.json +1 -1
package/bin/teams.mjs
CHANGED
|
@@ -165,7 +165,7 @@ async function main() {
|
|
|
165
165
|
case 'panes': {
|
|
166
166
|
if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('panes.list')));
|
|
167
167
|
if (sub === 'capture') return withNode(async (rpc) => out((await rpc.call('panes.capture', { pane: rest[0], lines: Number(flags.lines ?? 200) })).text));
|
|
168
|
-
if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0]
|
|
168
|
+
if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0] })));
|
|
169
169
|
die('panes ls|capture|close');
|
|
170
170
|
}
|
|
171
171
|
case 'title': {
|
package/connector/index.mjs
CHANGED
|
@@ -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)}:
|
|
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
|
|
@@ -625,9 +645,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
625
645
|
}
|
|
626
646
|
|
|
627
647
|
/**
|
|
628
|
-
* A session acts only on the panes it started: input, a turn
|
|
648
|
+
* A session acts only on the panes it started: input, a turn or a suspend from a session (`caller`, its
|
|
629
649
|
* address) on a pane another session, a board or a person started is refused, naming who started it. A person (no
|
|
630
|
-
* caller) and a board (its cards' own door) act on any pane.
|
|
650
|
+
* caller) and a board (its cards' own door) act on any pane. Close is available to any holder of pane control.
|
|
631
651
|
*/
|
|
632
652
|
/**
|
|
633
653
|
* Who is calling, for the pane doors. On the local socket the daemon decides: it hands the connection to its own
|
|
@@ -1277,7 +1297,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1277
1297
|
}
|
|
1278
1298
|
const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
|
|
1279
1299
|
// Who started the pane: the launching session's (or board's) address, null for a person. A session's later
|
|
1280
|
-
// input
|
|
1300
|
+
// input on the pane is refused unless it is this one (#refuseForeign); close is not creator-restricted.
|
|
1281
1301
|
const by = await this.#callerOf(entry, params).catch(() => null);
|
|
1282
1302
|
// Who to tell when the pane ends (`open --new … --notify-when-ended`): a session's mail address, and this
|
|
1283
1303
|
// machine's name as the address a notice from its pane comes from.
|
|
@@ -1356,8 +1376,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1356
1376
|
return rows;
|
|
1357
1377
|
}
|
|
1358
1378
|
case 'panes.close': {
|
|
1359
|
-
need(
|
|
1360
|
-
this.#refuseForeign(params.pane, await this.#callerOf(entry, params));
|
|
1379
|
+
need('control', `pane:${params.pane}`);
|
|
1361
1380
|
const session = await this.#paneByContext(params.pane);
|
|
1362
1381
|
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
1363
1382
|
await this.host.closeSession(session.id);
|
package/mail-history.mjs
ADDED
|
@@ -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.
|
|
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",
|