@volter/supercode-teams 0.2.33 → 0.2.35

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/LICENSE-MIT ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 supercode contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/fleet.mjs CHANGED
@@ -83,6 +83,8 @@ async function main() {
83
83
  cwd: flags.cwd,
84
84
  initialInput: flags.input,
85
85
  name: flags.name,
86
+ // `open` attaching this terminal to the pane itself asks the machine for no window of its own.
87
+ ...(flags.popup === 'false' ? { popup: false } : {}),
86
88
  }));
87
89
  } finally {
88
90
  channel.close();
@@ -11,7 +11,7 @@ const VALUES = new Set([
11
11
  'out', 'ttl', 'type', 'name', 'visibility', 'backfill', 'format', 'to',
12
12
  'grant', 'binding', 'caps', 'fidelity', 'cwd', 'root', 'workspace-from', 'since', 'until', 'session',
13
13
  'on', 'timeout', 'plan', 'tmux-socket', 'volter-issuer', 'delegated-issuer', 'workplace', 'organization', 'key', 'room',
14
- 'conversation', 'rh2',
14
+ 'conversation', 'rh2', 'scope',
15
15
  ]);
16
16
 
17
17
  export function parseArgs(argv) {
package/bin/workspace.mjs CHANGED
@@ -721,7 +721,8 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
721
721
  const daemonHome = teamsHome();
722
722
  const hostKeyId = publicKeyId(loadOrCreateKeyPair(paths(daemonHome).machineKey).publicKeyPem);
723
723
  const connector = new TeamsConnector({ server: context.origin, teamId: context.team_id, machineId: saved.machine.id, sourceId: saved.source.id, credential: saved.credential.token, stateDir: state.directory, core, hostKeyId });
724
- const machine = new TeamsMachine({ home: daemonHome, supercode: binary, ...(flags.tmux_socket ? { tmuxSocket: String(flags.tmux_socket) } : {}), link: { server: context.origin, teamId: context.team_id, machineId: saved.machine.id, credential: saved.credential.token, incarnationId: connector.state.incarnation_id, configuration: () => connector.configuration() } });
724
+ // A connected machine is a person's: each pane it launches opens a window on its screen (SUPERCODE_POPUPS=0: none).
725
+ const machine = new TeamsMachine({ home: daemonHome, supercode: binary, popups: process.env.SUPERCODE_POPUPS !== '0', ...(flags.tmux_socket ? { tmuxSocket: String(flags.tmux_socket) } : {}), link: { server: context.origin, teamId: context.team_id, machineId: saved.machine.id, credential: saved.credential.token, incarnationId: connector.state.incarnation_id, configuration: () => connector.configuration() } });
725
726
  machine.on('link', (event) => { if (event.event === 'replaced') process.stderr.write(`supercode teams: ${event.error}. Two connectors were running for this machine; stop one of them (\`supercode teams disconnect\` where it should not run).\n`); });
726
727
  const stopAll = async () => { await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
727
728
  try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
@@ -758,7 +759,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
758
759
  * `--workplace-only`. `unpublish --record-only` deletes the record here and leaves the workplace alone.
759
760
  */
760
761
  async function publishCommand(verb, sub, { flags, rest, output, context, team, client, home, env }) {
761
- only(flags, ['json', 'help', 'context', 'room', 'workplace', 'organization', 'name', 'key', 'machine', 'token_stdin', 'record_only', 'workplace_only']);
762
+ only(flags, ['json', 'help', 'context', 'room', 'workplace', 'organization', 'name', 'key', 'machine', 'token_stdin', 'record_only', 'workplace_only', 'scope']);
762
763
  const recordOnly = Boolean(flags.record_only);
763
764
  if (!flags.room || !flags.workplace || (!recordOnly && !flags.organization)) usage(`teams ${verb} needs --room ROOM --workplace ORIGIN --organization ORG`);
764
765
  const workplace = new URL(flags.workplace).origin;
@@ -807,7 +808,9 @@ async function publishCommand(verb, sub, { flags, rest, output, context, team, c
807
808
  const key = flags.key ?? `${sub === 'pane' ? 'pane' : 'folder'}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || createHash('sha256').update(target).digest('hex').slice(0, 8)}`;
808
809
  const kind = sub === 'pane' ? 'pane' : 'folder';
809
810
  const existed = (await recorded()).some(item => item.target === target && item.workplace === workplace && item.room === flags.room);
810
- const publication = (await client.request('POST', publications, { body: { kind, target, workplace, room: flags.room, surface_key: key } })).data;
811
+ // --scope view caps what the workplace's tokens can open the surface as (they can watch it, not drive it).
812
+ if (flags.scope !== undefined && flags.scope !== 'view' && flags.scope !== 'control') usage('--scope must be view or control');
813
+ const publication = (await client.request('POST', publications, { body: { kind, target, workplace, room: flags.room, surface_key: key, ...(flags.scope ? { max_scope: flags.scope } : {}) } })).data;
811
814
  if (recordOnly) return output({ version: 1, context: scope(context), data: { publication } });
812
815
  let payload;
813
816
  try {
@@ -913,7 +916,7 @@ function printHelp() {
913
916
  login [ORIGIN] [--team TEAM] [--as CONTEXT] [--token-stdin|--invite-stdin] (Volter sign-in in the browser by default)
914
917
  identity link
915
918
  server start [--volter-issuer ORIGIN] [--delegated-issuer ORIGIN]
916
- publish pane|root TARGET --room ROOM --workplace ORIGIN --organization ORG [--name] [--key] [--machine] [--token-stdin] [--record-only]
919
+ publish pane|root TARGET --room ROOM --workplace ORIGIN --organization ORG [--scope view|control] [--name] [--key] [--machine] [--token-stdin] [--record-only]
917
920
  unpublish SURFACE --room ROOM --workplace ORIGIN --organization ORG [--machine] [--token-stdin] [--record-only|--workplace-only]
918
921
  connect|disconnect|status
919
922
  members invite|list|remove|role
package/machine.mjs CHANGED
@@ -29,7 +29,7 @@ import { EventEmitter } from 'node:events';
29
29
  import { readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
30
30
  import { join, resolve, dirname } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
- import { hostname } from 'node:os';
32
+ import { homedir, hostname } from 'node:os';
33
33
  import { randomBytes, createHash } from 'node:crypto';
34
34
  import { WebSocket } from 'ws';
35
35
  import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
@@ -67,16 +67,64 @@ const COMPOSER_RECHECK_MS = 400;
67
67
  * must have stopped changing for this long before the pane counts as ready. */
68
68
  const INITIAL_INPUT_SETTLE_MS = 1_000;
69
69
 
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;
78
+ }
79
+
80
+ /** How long a launch watches its new pane before answering, so a program that ends at once is reported. */
81
+ const LAUNCH_SETTLE_MS = 500;
82
+
83
+ /**
84
+ * What a pane runs for a launch: the program with its arguments, or, when the caller named a whole command line
85
+ * as the program (`open --new "npm run build"`), that line through this machine's own shell, so a caller on
86
+ * another machine need not know which shell or platform this one has.
87
+ */
88
+ function launchCommand(program, args) {
89
+ const line = typeof program === 'string' ? program.trim() : '';
90
+ if (!args.length && /\s/.test(line) && !existsSync(line)) {
91
+ return process.platform === 'win32'
92
+ ? { program: 'powershell.exe', arguments: ['-NoProfile', '-Command', line] }
93
+ : { program: process.env.SHELL || '/bin/sh', arguments: ['-lc', line] };
94
+ }
95
+ return { program, arguments: args };
96
+ }
97
+
98
+ /**
99
+ * How a pane that is gone ended, from the pane-exit log its tmux server's hooks write (under SUPERCODE_HOME, or the
100
+ * user's ~/.config/supercode when the server's environment has none): { failed, how } or null when nothing is
101
+ * recorded. A pane that failed has a `died` line with its status or signal; one that ended cleanly only `closed`.
102
+ */
103
+ function paneExit(pane) {
104
+ const logs = [process.env.SUPERCODE_HOME, join(homedir(), '.config', 'supercode')].filter(Boolean).map((dir) => join(dir, 'pane-exits.log'));
105
+ for (const log of logs) {
106
+ let lines;
107
+ try { lines = readFileSync(log, 'utf8').split('\n'); } catch { continue; }
108
+ const died = lines.findLast((entry) => entry.includes(` ${pane} died `));
109
+ const match = died && /status=(\d*) signal=(\S*)/.exec(died);
110
+ if (match) return { failed: true, how: match[1] ? `status ${match[1]}` : match[2] ? `signal ${match[2]}` : 'failed' };
111
+ if (lines.some((entry) => entry.endsWith(` ${pane} closed`))) return { failed: false, how: 'status 0' };
112
+ }
113
+ return null;
114
+ }
115
+
70
116
  export class TeamsMachine extends MachineCore {
71
117
  /**
72
118
  * `link`: { server, teamId, machineId, credential, incarnationId, configuration } to hold the
73
119
  * server link; `configuration()` resolves the machine's current configuration from the server
74
120
  * (custodian and machine grants). Without it the daemon serves its local door only.
75
121
  */
76
- constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null } = {}) {
122
+ constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null, popups = false } = {}) {
77
123
  super({ home, floor, limits, env, link });
78
124
  this.supercodeBin = supercode;
79
125
  this.tmuxSocket = tmuxSocket ?? null;
126
+ // Each pane launched here opens a terminal window on this machine's screen (popupWindow).
127
+ this.popups = Boolean(popups);
80
128
  this.launches = new Launches(this.p.launches);
81
129
  // Windows has no tmux: its panes are ConPTY children of this daemon, and end with it.
82
130
  if (process.platform === 'win32' && !terminal.PtyTerminalHost) throw new Error('panes on Windows need a newer @volter/supercode-terminal (one with PtyTerminalHost)');
@@ -627,6 +675,26 @@ export class TeamsMachine extends MachineCore {
627
675
 
628
676
  listPorts() { return listAnnouncedPorts(this.portsDir); }
629
677
 
678
+ /**
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.
684
+ */
685
+ #popupWindow(contextKey, session) {
686
+ const wezterm = weztermProgram();
687
+ if (!wezterm) return;
688
+ const attach = process.platform === 'win32'
689
+ ? [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 */ }
696
+ }
697
+
630
698
  /**
631
699
  * Rename the tmux window behind an owned pane. The terminal host exposes
632
700
  * no rename, so this is `tmux` on the host's own socket; automatic-rename is
@@ -939,7 +1007,7 @@ export class TeamsMachine extends MachineCore {
939
1007
  let session;
940
1008
  try {
941
1009
  session = await this.host.createSession({
942
- command: { program: params.program, arguments: params.arguments ?? [] },
1010
+ command: launchCommand(params.program, params.arguments ?? []),
943
1011
  contextKey,
944
1012
  cwd: params.cwd,
945
1013
  env: this.#paneEnv(contextKey, params.env),
@@ -961,6 +1029,17 @@ export class TeamsMachine extends MachineCore {
961
1029
  // answers "did my message go in, and was it late?" without anyone
962
1030
  // reading the screen.
963
1031
  if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, input: params.initialInput, act });
1032
+ // A program that ends at once (not found, not runnable, failing on its first line) takes its pane with it:
1033
+ // the caller is told, with its exit status, instead of being handed a pane that is already gone.
1034
+ await new Promise((resolve) => setTimeout(resolve, LAUNCH_SETTLE_MS));
1035
+ if (!await this.#paneByContext(contextKey)) {
1036
+ // Finished (it ran to a clean end) is an answer; exited (it failed at once) is the caller's error.
1037
+ const exit = paneExit(contextKey);
1038
+ const status = exit && !exit.failed ? 'finished' : 'exited';
1039
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status, error: exit?.failed ? exit.how : undefined });
1040
+ return { key, pane: contextKey, reused: false, status, exit: exit?.how ?? null };
1041
+ }
1042
+ if (this.popups && params.popup !== false) this.#popupWindow(contextKey, session);
964
1043
  return { key, pane: contextKey, reused: false, status: 'ready' };
965
1044
  }
966
1045
  case 'launches.get': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
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",
@@ -39,6 +39,7 @@
39
39
  "supercode-teams-fleet": "./bin/fleet.mjs"
40
40
  },
41
41
  "files": [
42
+ "LICENSE-MIT",
42
43
  "*.mjs",
43
44
  "workspace-client.d.ts",
44
45
  "porthole-hook.cjs",