@volter/supercode-teams 0.2.114 → 0.2.116

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 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/machine.mjs CHANGED
@@ -50,6 +50,7 @@ import { DEFAULT_RANK, normalizeRank, judge } from './attention.mjs';
50
50
  import { popupTerminal, openPopup } from './popup-terminals.mjs';
51
51
  import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from './execution.mjs';
52
52
  import { confirmNativeInitialInput } from './native-initial-input.mjs';
53
+ import { settleCodexHookReview } from './codex-startup.mjs';
53
54
 
54
55
  /** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
55
56
  const PORTHOLE_HOOK = fileURLToPath(new URL('./porthole-hook.cjs', import.meta.url));
@@ -188,6 +189,7 @@ export class TeamsMachine extends MachineCore {
188
189
  this.voiceProcesses = new Map(); // session address -> optional voice pack child
189
190
  this.inputInFlight = new Set(); // one input transaction per pane
190
191
  this.pendingInitialInputs = new Map(); // launch key → { abort, task }
192
+ this.pendingStartups = new Map();
191
193
  this.registerDoor('harness', (entry, stream, accept, refuse, index) => this.#serveHarness(index, entry, stream, accept, refuse));
192
194
  this.registerDoor('terminal', (entry, stream, accept, refuse) => this.#attachTerminal(entry, stream, accept, refuse));
193
195
  this.registerDoor('node', (entry, stream, accept) => { accept(); return this.#serveNodeRpc(entry, stream); });
@@ -216,7 +218,10 @@ export class TeamsMachine extends MachineCore {
216
218
  // this daemon is going away, and shoving a message at a half-booted TUI on the
217
219
  // way out is the very failure the wait exists to prevent. The launch record
218
220
  // already says the input was never delivered.
221
+ for (const { abort } of this.pendingStartups.values()) abort.abort();
219
222
  for (const { abort } of this.pendingInitialInputs.values()) abort.abort();
223
+ await Promise.allSettled([...this.pendingStartups.values()].map(({ task }) => task));
224
+ this.pendingStartups.clear();
220
225
  await Promise.allSettled([...this.pendingInitialInputs.values()].map(({ task }) => task));
221
226
  this.pendingInitialInputs.clear();
222
227
  }
@@ -803,6 +808,20 @@ export class TeamsMachine extends MachineCore {
803
808
  }
804
809
  }
805
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
+
806
825
  // ---- launch: the first input ------------------------------------------
807
826
 
808
827
  /**
@@ -1323,6 +1342,7 @@ export class TeamsMachine extends MachineCore {
1323
1342
  throw error;
1324
1343
  }
1325
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);
1326
1346
  act('launch', `pane:${contextKey}`, 'ok', params.kind ?? params.program);
1327
1347
  if (typeof params.name === 'string' && params.name.trim()) {
1328
1348
  const titles = readJson(this.p.titles, { v: 1, titles: {} });
@@ -1377,6 +1397,7 @@ export class TeamsMachine extends MachineCore {
1377
1397
  }
1378
1398
  case 'panes.close': {
1379
1399
  need('control', `pane:${params.pane}`);
1400
+ for (const pending of this.pendingStartups.values()) if (pending.pane === params.pane) pending.abort.abort();
1380
1401
  const session = await this.#paneByContext(params.pane);
1381
1402
  if (!session) throw new Error(`no pane with context ${params.pane}`);
1382
1403
  await this.host.closeSession(session.id);
package/mail-history.mjs CHANGED
@@ -4,6 +4,47 @@ import { hostname } from 'node:os';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { teamsHome } from '@volter/teams/home';
6
6
 
7
+ const directories = new Map();
8
+ function sessionDirectory(binary, env, signal) {
9
+ const key = `${binary}:${teamsHome(env)}`;
10
+ const cached = directories.get(key);
11
+ if (cached && cached.until > Date.now()) return cached.value;
12
+ const value = new Promise((resolve, reject) => {
13
+ const child = spawn(binary, ['message', 'list', '--json'], { env, signal: AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(15_000)]), stdio: ['ignore', 'pipe', 'ignore'] });
14
+ let out = '';
15
+ child.stdout.on('data', chunk => { out += chunk; if (out.length > 4_000_000) child.kill(); });
16
+ child.on('error', reject);
17
+ child.on('close', code => { try { if (code !== 0) throw new Error('Session directory unavailable'); resolve(JSON.parse(out)); } catch (error) { reject(error); } });
18
+ }).catch(() => []);
19
+ directories.set(key, { until: Date.now() + 60_000, value });
20
+ return value;
21
+ }
22
+
23
+ /** Resolve from supercode's existing session directory and explicit voice
24
+ * backing, never by interpreting an operator mailbox's generated name. */
25
+ function mailActors(rows, sessions, self) {
26
+ const directory = new Map(sessions.map(session => [session.address, session]));
27
+ const voices = new Map(rows.filter(row => row.envelope.voice_for).map(row => [row.envelope.from, row.envelope.voice_for]));
28
+ const names = new Map(rows.map(row => [row.envelope.from, row.envelope.from_name]));
29
+ const actor = (address) => {
30
+ const backing = voices.get(address) || address;
31
+ const native = /^sc:([^:]+):([^:]+):(.+)$/.exec(backing);
32
+ const session = directory.get(backing);
33
+ const kind = native && !['operator', 'board', 'pane'].includes(native[2]) ? 'agent' : 'automation';
34
+ let name = session?.name || names.get(backing) || (native?.[2] === 'board' ? 'Board' : kind === 'agent' ? native[2] : 'Automation');
35
+ if (native && name.endsWith(`@${native[1]}`)) name = name.slice(0, -native[1].length - 1);
36
+ // An unnamed session gets its harness label, not its address as a name.
37
+ if (/^(?:codex|claude-code|claude|gemini|pi|goose|grok|opencode)-[0-9a-f]{8,}$/i.test(name)) name = native[2] === 'codex' ? 'Codex' : native[2];
38
+ return { id: backing, name, kind, self: backing === self, ...(native ? { session: { machine: native[1], harness: native[2], id: native[3] } } : {}), ...(voices.has(address) ? { via: 'voice' } : {}) };
39
+ };
40
+ return rows.map(({ envelope, session: to, direction, delivery }) => ({
41
+ id: envelope.id, from: envelope.from, fromName: envelope.from_name, to,
42
+ fromActor: actor(envelope.from), toActor: actor(to),
43
+ text: envelope.body, at: envelope.created_at_ms, direction,
44
+ inReplyTo: envelope.in_reply_to ?? null, delivery: delivery?.state ?? 'unknown',
45
+ }));
46
+ }
47
+
7
48
  // Match the CLI's local_machine_name: the native mailbox belongs to its
8
49
  // enrolled context, which can differ from a viewer connector's --home.
9
50
  function machineName(env) {
@@ -30,21 +71,17 @@ export function readMailHistory({ binary, env, harness, session, signal }) {
30
71
  const address = `sc:${machineName(env)}:${harness}:${session}`;
31
72
  return new Promise((resolve, reject) => {
32
73
  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)]),
74
+ env, stdio: ['ignore', 'pipe', 'pipe'], signal: AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(20_000)]),
34
75
  });
35
76
  let out = '', error = '';
36
77
  child.stdout.on('data', chunk => { out += chunk; if (out.length > 8_000_000) child.kill(); });
37
78
  child.stderr.on('data', chunk => { error = (error + chunk).slice(-1000); });
38
79
  child.on('error', reject);
39
- child.on('close', code => {
80
+ child.on('close', async code => {
40
81
  if (code !== 0) return reject(new Error(error || 'Mailbox could not be read'));
41
82
  try {
42
83
  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
- })));
84
+ resolve(mailActors(rows, await sessionDirectory(binary, env, signal), address));
48
85
  } catch (error) { reject(error); }
49
86
  });
50
87
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.114",
3
+ "version": "0.2.116",
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",