@volter/supercode-teams 0.2.113 → 0.2.115
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/workspace.mjs +6 -2
- package/codex-startup.mjs +56 -0
- package/connector/index.mjs +9 -3
- package/machine.mjs +41 -0
- package/mail-history.mjs +51 -0
- package/package.json +1 -1
package/bin/workspace.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
4
5
|
import { basename, join, resolve } from 'node:path';
|
|
5
6
|
import { execFileSync, spawn } from 'node:child_process';
|
|
@@ -698,10 +699,13 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
698
699
|
if (flags.install || (!flags.foreground && process.stdin.isTTY && process.stdout.isTTY)) {
|
|
699
700
|
const serviceBin = process.env.SUPERCODE_BIN;
|
|
700
701
|
if (!serviceBin) unsupported('--install requires invocation through the native supercode CLI');
|
|
702
|
+
const serviceEnv = { ...process.env, SUPERCODE_TEAMS_ENTRY: fileURLToPath(new URL('./teams.mjs', import.meta.url)) };
|
|
703
|
+
// Refuse source-backed installs before Windows stops the running connector.
|
|
704
|
+
execFileSync(serviceBin, ['__teams-connector-service', 'validate', contextName, path, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: serviceEnv });
|
|
701
705
|
// Ending a Windows task ends only the console host it started, never the connector under it, so the task
|
|
702
706
|
// would refuse its new run while the old connector went on: the running one is asked to stop first.
|
|
703
707
|
if (process.platform === 'win32') await stopRunningConnector(saved);
|
|
704
|
-
try { service = JSON.parse(execFileSync(serviceBin, ['__teams-connector-service', 'install', contextName, path, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); }
|
|
708
|
+
try { service = JSON.parse(execFileSync(serviceBin, ['__teams-connector-service', 'install', contextName, path, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: serviceEnv })); }
|
|
705
709
|
catch (error) {
|
|
706
710
|
// A platform with no service manager still connects: here, for as long as this terminal runs it. Anything else is an error.
|
|
707
711
|
if (flags.install || !/no service manager/.test(String(error.stderr ?? error.message))) { core.close(); throw error; }
|
|
@@ -768,7 +772,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
768
772
|
watchCodeChange([process.env.SUPERCODE_BIN, new URL('../package.json', import.meta.url), shim], (changed) => {
|
|
769
773
|
process.stderr.write(`supercode teams: ${changed} changed under this connector (an upgrade); moving to the new code\n`);
|
|
770
774
|
const cli = process.platform === 'win32' ? 'supercode' : process.env.SUPERCODE_BIN;
|
|
771
|
-
spawn(cli, ['teams', 'connect', '--install', '--context', contextName, '--cwd', path], { shell: process.platform === 'win32', detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
775
|
+
spawn(cli, ['teams', 'connect', '--install', '--context', contextName, '--cwd', path], { env: { ...process.env, SUPERCODE_TEAMS_ENTRY: fileURLToPath(new URL('./teams.mjs', import.meta.url)) }, shell: process.platform === 'win32', detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
772
776
|
// An explicit exit: the command's own return would end the process with 0, which systemd's on-failure policy
|
|
773
777
|
// does not restart.
|
|
774
778
|
if (process.platform !== 'win32') setTimeout(async () => { await stopConnector?.(); process.exit(75); }, 60_000);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Codex can still show hook review on resume despite its invocation trust flag.
|
|
2
|
+
// Act only on this exact startup menu, and never submit without reading selection.
|
|
3
|
+
import { composerText } from '@volter/supercode-terminal';
|
|
4
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
|
|
6
|
+
function hookReview(screen) {
|
|
7
|
+
const lines = String(screen).split('\n').map((line) => line.trim()).filter(Boolean);
|
|
8
|
+
const start = lines.lastIndexOf('Hooks need review');
|
|
9
|
+
if (start < 0) return null;
|
|
10
|
+
const menu = lines.slice(start);
|
|
11
|
+
if (menu.length !== 7 || !/^\d+ hooks? (?:is|are) new or changed\.$/.test(menu[1]) ||
|
|
12
|
+
menu[2] !== 'Hooks can run outside the sandbox after you trust them.' ||
|
|
13
|
+
!/^(?:enter confirm · esc skip|Press enter to confirm or esc to go back)$/.test(menu[6])) return null;
|
|
14
|
+
const labels = ['1. Review hooks', '2. Trust all and continue', "3. Continue without trusting (hooks won't run)"];
|
|
15
|
+
let selected = null;
|
|
16
|
+
for (let i = 0; i < labels.length; i += 1) {
|
|
17
|
+
if (menu[i + 3] === `› ${labels[i]}`) {
|
|
18
|
+
if (selected !== null) return null;
|
|
19
|
+
selected = i + 1;
|
|
20
|
+
} else if (menu[i + 3] !== labels[i]) return null;
|
|
21
|
+
}
|
|
22
|
+
return selected === null ? null : { selected, identity: menu.slice(0, 3).join('\n') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function settleCodexHookReview(host, sessionId, { signal, deadlineMs, live, claim, release } = {}) {
|
|
26
|
+
const expired = () => signal.aborted || Date.now() >= deadlineMs;
|
|
27
|
+
const read = () => host.capture(sessionId, { lines: 120 });
|
|
28
|
+
let readySince = null;
|
|
29
|
+
while (!expired()) {
|
|
30
|
+
if (!await live()) return 'pane-gone';
|
|
31
|
+
const screen = await read();
|
|
32
|
+
const menu = hookReview(screen);
|
|
33
|
+
if (menu) {
|
|
34
|
+
if (!claim()) return 'input-in-flight';
|
|
35
|
+
try {
|
|
36
|
+
// A fresh capture protects against a person moving or closing the menu.
|
|
37
|
+
let current = hookReview(await read());
|
|
38
|
+
if (!current || current.identity !== menu.identity || expired()) return 'changed';
|
|
39
|
+
if (current.selected !== 2) {
|
|
40
|
+
await host.sendInput(sessionId, current.selected === 1 ? '\x1b[B' : '\x1b[A', { submitMode: 'none', deadlineMs });
|
|
41
|
+
await pause(100);
|
|
42
|
+
current = hookReview(await read());
|
|
43
|
+
}
|
|
44
|
+
if (!current || current.identity !== menu.identity || current.selected !== 2 || !await live() || expired()) return 'changed';
|
|
45
|
+
await host.sendInput(sessionId, '\r', { submitMode: 'single', deadlineMs });
|
|
46
|
+
// Exactly one Enter. Delivery is still established by the native receipt.
|
|
47
|
+
return 'trust-selected';
|
|
48
|
+
} finally { release(); }
|
|
49
|
+
}
|
|
50
|
+
const composer = composerText('codex', screen);
|
|
51
|
+
readySince = composer !== null ? (readySince ?? Date.now()) : null;
|
|
52
|
+
if (readySince !== null && Date.now() - readySince >= 1000) return 'composer';
|
|
53
|
+
await pause(150);
|
|
54
|
+
}
|
|
55
|
+
return signal.aborted ? 'aborted' : 'deadline';
|
|
56
|
+
}
|
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';
|
|
@@ -49,6 +50,7 @@ import { DEFAULT_RANK, normalizeRank, judge } from './attention.mjs';
|
|
|
49
50
|
import { popupTerminal, openPopup } from './popup-terminals.mjs';
|
|
50
51
|
import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from './execution.mjs';
|
|
51
52
|
import { confirmNativeInitialInput } from './native-initial-input.mjs';
|
|
53
|
+
import { settleCodexHookReview } from './codex-startup.mjs';
|
|
52
54
|
|
|
53
55
|
/** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
|
|
54
56
|
const PORTHOLE_HOOK = fileURLToPath(new URL('./porthole-hook.cjs', import.meta.url));
|
|
@@ -187,6 +189,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
187
189
|
this.voiceProcesses = new Map(); // session address -> optional voice pack child
|
|
188
190
|
this.inputInFlight = new Set(); // one input transaction per pane
|
|
189
191
|
this.pendingInitialInputs = new Map(); // launch key → { abort, task }
|
|
192
|
+
this.pendingStartups = new Map();
|
|
190
193
|
this.registerDoor('harness', (entry, stream, accept, refuse, index) => this.#serveHarness(index, entry, stream, accept, refuse));
|
|
191
194
|
this.registerDoor('terminal', (entry, stream, accept, refuse) => this.#attachTerminal(entry, stream, accept, refuse));
|
|
192
195
|
this.registerDoor('node', (entry, stream, accept) => { accept(); return this.#serveNodeRpc(entry, stream); });
|
|
@@ -215,7 +218,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
215
218
|
// this daemon is going away, and shoving a message at a half-booted TUI on the
|
|
216
219
|
// way out is the very failure the wait exists to prevent. The launch record
|
|
217
220
|
// already says the input was never delivered.
|
|
221
|
+
for (const { abort } of this.pendingStartups.values()) abort.abort();
|
|
218
222
|
for (const { abort } of this.pendingInitialInputs.values()) abort.abort();
|
|
223
|
+
await Promise.allSettled([...this.pendingStartups.values()].map(({ task }) => task));
|
|
224
|
+
this.pendingStartups.clear();
|
|
219
225
|
await Promise.allSettled([...this.pendingInitialInputs.values()].map(({ task }) => task));
|
|
220
226
|
this.pendingInitialInputs.clear();
|
|
221
227
|
}
|
|
@@ -451,6 +457,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
451
457
|
const { admitted, principal } = entry;
|
|
452
458
|
const request = stream.params?.request;
|
|
453
459
|
if (request?.op === 'turn') return this.#serveTurn(entry, request, stream, accept, refuse);
|
|
460
|
+
if (request?.op === 'history') return this.#serveMailHistory(entry, request, stream, accept, refuse);
|
|
454
461
|
const to = typeof request?.to === 'string' ? request.to : '*';
|
|
455
462
|
const target = to.startsWith('sc:') ? `mail:${to}` : 'mail:*';
|
|
456
463
|
if (!request || !['send', 'file', 'list'].includes(request.op)) return refuse('mail needs a send, file, list or turn request');
|
|
@@ -475,6 +482,24 @@ export class TeamsMachine extends MachineCore {
|
|
|
475
482
|
child.stdin.end(JSON.stringify({ ...request, via_principal: principal ?? null }));
|
|
476
483
|
}
|
|
477
484
|
|
|
485
|
+
#serveMailHistory(entry, request, stream, accept, refuse) {
|
|
486
|
+
const { admitted, principal } = entry;
|
|
487
|
+
const granted = (admitted?.grants ?? []).filter(grant => grant.caps.includes('observe')).flatMap(grant => grant.targets).filter(target => target.startsWith('session:') && target !== 'session:*');
|
|
488
|
+
const named = typeof request.harness === 'string' && typeof request.session === 'string' ? `${request.harness}/${request.session}` : granted.length === 1 ? granted[0].slice(8) : '';
|
|
489
|
+
const [harness, session, extra] = named.split('/');
|
|
490
|
+
const target = `session:${named}`;
|
|
491
|
+
if (!harness || !session || extra || !holds(admitted, 'observe', target)) return refuse('no observe grant for this session');
|
|
492
|
+
accept();
|
|
493
|
+
const controller = new AbortController();
|
|
494
|
+
stream.on('close', () => controller.abort());
|
|
495
|
+
void readMailHistory({ binary: this.supercodeBin, env: this.env, harness, session, signal: controller.signal }).then(rows => {
|
|
496
|
+
if (!stream.closed) stream.write(JSON.stringify({ code: 0, rows }));
|
|
497
|
+
this.log.append({ principal, cap: 'observe', target, outcome: 'mail-history' });
|
|
498
|
+
}, error => {
|
|
499
|
+
if (!stream.closed) stream.write(JSON.stringify({ code: 1, text: error.message }));
|
|
500
|
+
}).finally(() => { if (!stream.closed) stream.close('complete'); });
|
|
501
|
+
}
|
|
502
|
+
|
|
478
503
|
#serveTurn(entry, request, stream, accept, refuse) {
|
|
479
504
|
// A person's line typed into one session here as that session's own user turn (a Room member in a Conversation
|
|
480
505
|
// bound to the session): `interact` on `session:<harness>/<id>`. Who typed it comes from the caller's claim
|
|
@@ -783,6 +808,20 @@ export class TeamsMachine extends MachineCore {
|
|
|
783
808
|
}
|
|
784
809
|
}
|
|
785
810
|
|
|
811
|
+
#settleCodexStartup(key, pane, session, act) {
|
|
812
|
+
for (const pending of this.pendingStartups.values()) if (pending.pane === pane) pending.abort.abort();
|
|
813
|
+
const abort = new AbortController();
|
|
814
|
+
const task = settleCodexHookReview(this.host, session.id, {
|
|
815
|
+
signal: abort.signal, deadlineMs: Date.now() + INITIAL_INPUT_READY_DEADLINE_MS,
|
|
816
|
+
live: async () => (await this.#paneByContext(pane))?.id === session.id,
|
|
817
|
+
claim: () => { if (this.inputInFlight.has(session.id)) return false; this.inputInFlight.add(session.id); return true; },
|
|
818
|
+
release: () => this.inputInFlight.delete(session.id),
|
|
819
|
+
}).then((result) => { if (result === 'trust-selected') act('launch', `pane:${pane}`, 'ok', 'Codex startup hook review: trust selected and confirmed once'); })
|
|
820
|
+
.catch((error) => act('launch', `pane:${pane}`, 'failed', `Codex startup review: ${error.message}`))
|
|
821
|
+
.finally(() => { if (this.pendingStartups.get(key)?.task === task) this.pendingStartups.delete(key); });
|
|
822
|
+
this.pendingStartups.set(key, { pane, abort, task });
|
|
823
|
+
}
|
|
824
|
+
|
|
786
825
|
// ---- launch: the first input ------------------------------------------
|
|
787
826
|
|
|
788
827
|
/**
|
|
@@ -1303,6 +1342,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1303
1342
|
throw error;
|
|
1304
1343
|
}
|
|
1305
1344
|
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'ready' });
|
|
1345
|
+
if (params.kind === 'codex' && (params.arguments ?? []).includes('--dangerously-bypass-hook-trust')) this.#settleCodexStartup(key, contextKey, session, act);
|
|
1306
1346
|
act('launch', `pane:${contextKey}`, 'ok', params.kind ?? params.program);
|
|
1307
1347
|
if (typeof params.name === 'string' && params.name.trim()) {
|
|
1308
1348
|
const titles = readJson(this.p.titles, { v: 1, titles: {} });
|
|
@@ -1357,6 +1397,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1357
1397
|
}
|
|
1358
1398
|
case 'panes.close': {
|
|
1359
1399
|
need('control', `pane:${params.pane}`);
|
|
1400
|
+
for (const pending of this.pendingStartups.values()) if (pending.pane === params.pane) pending.abort.abort();
|
|
1360
1401
|
const session = await this.#paneByContext(params.pane);
|
|
1361
1402
|
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
1362
1403
|
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.115",
|
|
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",
|