@volter/supercode-teams 0.2.35 → 0.2.37

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.
@@ -151,7 +151,13 @@ export class TeamsConnector {
151
151
  if (running || this.abort.signal.aborted) return null;
152
152
  running = true;
153
153
  const run = (async () => { this.localRoots = await roots(); await pass(); })();
154
- void run.catch(error => { this.lastError = error; }).finally(() => { running = false; });
154
+ // A pass that fails is said in the connector's log (once per distinct failure): nothing syncs until it passes.
155
+ void run.catch(error => {
156
+ this.lastError = error;
157
+ const said = `${error?.code ?? ''} ${error?.message ?? error}`;
158
+ if (said !== this.lastPassFailure) process.stderr.write(`supercode teams: a sync pass failed: ${error?.message ?? error}\n`);
159
+ this.lastPassFailure = said;
160
+ }).then(() => { if (run && !this.lastError) this.lastPassFailure = null; }).finally(() => { running = false; });
155
161
  return run;
156
162
  };
157
163
  /** The first sync pass, for a caller that waits on it; the connector keeps running if it fails. */
package/machine.mjs CHANGED
@@ -45,6 +45,7 @@ import { fingerprint, signBytes, canonical } from '@volter/teams/identity';
45
45
  import { teamsHome, paths, readJson, writeJson, Launches } from '@volter/teams/home';
46
46
  import { request as httpRequest } from 'node:http';
47
47
  import { DEFAULT_RANK, normalizeRank, judge } from './attention.mjs';
48
+ import { popupTerminal, openPopup } from './popup-terminals.mjs';
48
49
  import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from './execution.mjs';
49
50
 
50
51
  /** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
@@ -67,14 +68,29 @@ const COMPOSER_RECHECK_MS = 400;
67
68
  * must have stopped changing for this long before the pane counts as ready. */
68
69
  const INITIAL_INPUT_SETTLE_MS = 1_000;
69
70
 
70
- /** The WezTerm this machine has, on PATH or where its installer puts it, or null. */
71
- function weztermProgram() {
72
- const candidates = process.platform === 'win32'
73
- ? [join(process.env.ProgramFiles ?? 'C:\\Program Files', 'WezTerm', 'wezterm.exe')]
74
- : ['/opt/homebrew/bin/wezterm', '/usr/local/bin/wezterm', '/Applications/WezTerm.app/Contents/MacOS/wezterm', '/usr/bin/wezterm'];
75
- const onPath = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['wezterm'], { encoding: 'utf8', timeout: 3_000 });
76
- const found = onPath.status === 0 ? onPath.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) : null;
77
- return found ?? candidates.find((path) => existsSync(path)) ?? null;
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 */ }
78
94
  }
79
95
 
80
96
  /** How long a launch watches its new pane before answering, so a program that ends at once is reported. */
@@ -362,9 +378,10 @@ export class TeamsMachine extends MachineCore {
362
378
  // receiver named rather than addressed needs `mail:*`.
363
379
  const { admitted, principal } = entry;
364
380
  const request = stream.params?.request;
381
+ if (request?.op === 'turn') return this.#serveTurn(entry, request, stream, accept, refuse);
365
382
  const to = typeof request?.to === 'string' ? request.to : '*';
366
383
  const target = to.startsWith('sc:') ? `mail:${to}` : 'mail:*';
367
- 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');
368
385
  if (!holds(admitted, 'mail', target)) {
369
386
  this.log.append({ principal, cap: 'mail', target, outcome: 'refused' });
370
387
  return refuse(`no mail grant for ${target}`);
@@ -386,6 +403,37 @@ export class TeamsMachine extends MachineCore {
386
403
  child.stdin.end(JSON.stringify({ ...request, via_principal: principal ?? null }));
387
404
  }
388
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
+
389
437
  // ---- terminal ------------------------------------------------------------
390
438
 
391
439
  async #attachTerminal(entry, stream, accept, refuse) {
@@ -676,23 +724,17 @@ export class TeamsMachine extends MachineCore {
676
724
  listPorts() { return listAnnouncedPorts(this.portsDir); }
677
725
 
678
726
  /**
679
- * A terminal window on this machine's screen showing the pane, so the people at the machine see what runs on it.
680
- * WezTerm runs one process per window (`start --always-new-process`) with an attach client in it: the pane ending
681
- * ends the client and the window closes, and a person closing the window ends only the client, never the pane (the
682
- * pane lives in tmux, or in this daemon's ConPTY host on Windows). No OS automation permission is involved. Without
683
- * WezTerm there is no window, as before.
727
+ * A terminal window on this machine's screen showing the pane, so the people at the machine see what runs on it:
728
+ * in whichever capable terminal is installed (popup-terminals.mjs), running an attach client. The pane ending ends
729
+ * the client and the window closes; a person closing the window ends only the client, never the pane.
684
730
  */
685
731
  #popupWindow(contextKey, session) {
686
- const wezterm = weztermProgram();
687
- if (!wezterm) return;
732
+ const terminal = popupTerminal(this.env);
733
+ if (!terminal) return;
688
734
  const attach = process.platform === 'win32'
689
735
  ? [process.execPath, fileURLToPath(new URL('./bin/fleet.mjs', import.meta.url)), 'attach', contextKey, '--mode', 'control']
690
- : ['tmux', ...(this.tmuxSocket ? ['-L', this.tmuxSocket] : []), 'attach-session', '-t', session.label ?? contextKey];
691
- try {
692
- const child = spawn(wezterm, ['start', '--always-new-process', '--', ...attach], { detached: true, stdio: 'ignore', env: { ...this.env, TMUX: '' } });
693
- child.on('error', () => {});
694
- child.unref();
695
- } catch { /* no window; the pane runs regardless */ }
736
+ : [spawnSync('which', ['tmux'], { encoding: 'utf8' }).stdout.trim() || 'tmux', ...(this.tmuxSocket ? ['-L', this.tmuxSocket] : []), 'attach-session', '-t', session.label ?? contextKey];
737
+ openPopup(terminal, attach, { ...this.env, TMUX: '' });
696
738
  }
697
739
 
698
740
  /**
@@ -1004,6 +1046,7 @@ export class TeamsMachine extends MachineCore {
1004
1046
  }
1005
1047
  const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
1006
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);
1007
1050
  let session;
1008
1051
  try {
1009
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.35",
3
+ "version": "0.2.37",
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",
@@ -0,0 +1,81 @@
1
+ // The terminal a pane's window opens in: whichever capable terminal this machine has. Each adapter opens a new window
2
+ // running one command (the pane's attach client) through the terminal's own launch interface: never AppleScript, so
3
+ // no automation permission is asked for. Closing the window ends only the attach client, never the pane (tmux, or the
4
+ // daemon's ConPTY host on Windows, holds it); the pane ending ends the client, and the terminal closes the window as
5
+ // its command exits. Adapters are tried in rank order; SUPERCODE_POPUP_TERMINAL names one (or `none`).
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { chmodSync, existsSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+
12
+ const onPath = (name) => {
13
+ const found = spawnSync(process.platform === 'win32' ? 'where' : 'which', [name], { encoding: 'utf8', timeout: 3_000 });
14
+ return found.status === 0 ? found.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null : null;
15
+ };
16
+ const firstPath = (...paths) => paths.find((path) => path && existsSync(path)) ?? null;
17
+ const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files';
18
+ const localApps = process.env.LOCALAPPDATA ?? '';
19
+
20
+ /** A shell script that deletes itself and then becomes `argv`: what a terminal opened on a file runs. */
21
+ function selfDeletingScript(argv, extension) {
22
+ const path = join(tmpdir(), `supercode-pane-${randomBytes(6).toString('hex')}${extension}`);
23
+ const quoted = argv.map((arg) => `'${String(arg).replaceAll("'", `'"'"'`)}'`).join(' ');
24
+ writeFileSync(path, `#!/bin/sh\nrm -f -- "$0"\nexec ${quoted}\n`);
25
+ chmodSync(path, 0o700);
26
+ return path;
27
+ }
28
+
29
+ /** Adapters by platform, in rank order: { name, find() → program or null, launch(program, argv) → [command, args] }. */
30
+ const ADAPTERS = {
31
+ darwin: [
32
+ { name: 'wezterm', find: () => onPath('wezterm') ?? firstPath('/Applications/WezTerm.app/Contents/MacOS/wezterm'), launch: (p, argv) => [p, ['start', '--always-new-process', '--', ...argv]] },
33
+ { name: 'kitty', find: () => onPath('kitty') ?? firstPath('/Applications/kitty.app/Contents/MacOS/kitty'), launch: (p, argv) => [p, argv] },
34
+ { name: 'ghostty', find: () => firstPath('/Applications/Ghostty.app'), launch: (p, argv) => ['open', ['-na', p, '--args', '-e', ...argv]] },
35
+ { name: 'alacritty', find: () => onPath('alacritty') ?? firstPath('/Applications/Alacritty.app/Contents/MacOS/alacritty'), launch: (p, argv) => [p, ['-e', ...argv]] },
36
+ // iTerm2 and Terminal run a script file they are asked to open: a plain `open`, not a scripting bridge.
37
+ { name: 'iterm2', find: () => firstPath('/Applications/iTerm.app'), launch: (p, argv) => ['open', ['-a', p, selfDeletingScript(argv, '.command')]] },
38
+ { name: 'terminal', find: () => firstPath('/System/Applications/Utilities/Terminal.app', '/Applications/Utilities/Terminal.app'), launch: (p, argv) => ['open', ['-a', p, selfDeletingScript(argv, '.command')]] },
39
+ ],
40
+ win32: [
41
+ { name: 'wezterm', find: () => onPath('wezterm') ?? firstPath(join(programFiles, 'WezTerm', 'wezterm.exe')), launch: (p, argv) => [p, ['start', '--always-new-process', '--', ...argv]] },
42
+ { name: 'alacritty', find: () => onPath('alacritty') ?? firstPath(join(programFiles, 'Alacritty', 'alacritty.exe')), launch: (p, argv) => [p, ['-e', ...argv]] },
43
+ { name: 'windows-terminal', find: () => onPath('wt') ?? firstPath(join(localApps, 'Microsoft', 'WindowsApps', 'wt.exe')), launch: (p, argv) => [p, ['-w', 'new', 'new-tab', '--', ...argv]] },
44
+ // A console window of its own, on every Windows: it lives exactly as long as its command.
45
+ { name: 'conhost', find: () => firstPath(join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'conhost.exe')), launch: (p, argv) => [p, argv] },
46
+ ],
47
+ linux: [
48
+ { name: 'wezterm', find: () => onPath('wezterm'), launch: (p, argv) => [p, ['start', '--always-new-process', '--', ...argv]] },
49
+ { name: 'kitty', find: () => onPath('kitty'), launch: (p, argv) => [p, argv] },
50
+ { name: 'ghostty', find: () => onPath('ghostty'), launch: (p, argv) => [p, ['-e', ...argv]] },
51
+ { name: 'alacritty', find: () => onPath('alacritty'), launch: (p, argv) => [p, ['-e', ...argv]] },
52
+ { name: 'xterm', find: () => onPath('xterm'), launch: (p, argv) => [p, ['-e', ...argv]] },
53
+ ],
54
+ };
55
+
56
+ /** The terminal a window opens in here: the one SUPERCODE_POPUP_TERMINAL names, else the best installed; or null. */
57
+ export function popupTerminal(env = process.env, platform = process.platform) {
58
+ const adapters = ADAPTERS[platform] ?? [];
59
+ const wanted = env.SUPERCODE_POPUP_TERMINAL?.trim().toLowerCase();
60
+ if (wanted === 'none') return null;
61
+ // A display to show a window on: macOS and Windows always have one for a signed-in user; Linux needs X or Wayland.
62
+ if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) return null;
63
+ for (const adapter of wanted ? adapters.filter((item) => item.name === wanted) : adapters) {
64
+ const program = adapter.find();
65
+ if (program) return { name: adapter.name, program, launch: (argv) => adapter.launch(program, argv) };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Opens a window running `argv` in `terminal` (from popupTerminal). Best effort: the pane runs whatever happens here. */
71
+ export function openPopup(terminal, argv, env) {
72
+ try {
73
+ const [command, args] = terminal.launch(argv);
74
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', env, windowsHide: false });
75
+ child.on('error', () => {});
76
+ child.unref();
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }