@volter/supercode-teams 0.2.34 → 0.2.36
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/fleet.mjs +2 -0
- package/bin/workspace.mjs +2 -1
- package/machine.mjs +67 -3
- package/package.json +1 -1
- package/popup-terminals.mjs +81 -0
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();
|
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
|
-
|
|
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; }
|
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';
|
|
@@ -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,16 +68,54 @@ 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
|
|
|
71
|
+
/** How long a launch watches its new pane before answering, so a program that ends at once is reported. */
|
|
72
|
+
const LAUNCH_SETTLE_MS = 500;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What a pane runs for a launch: the program with its arguments, or, when the caller named a whole command line
|
|
76
|
+
* as the program (`open --new "npm run build"`), that line through this machine's own shell, so a caller on
|
|
77
|
+
* another machine need not know which shell or platform this one has.
|
|
78
|
+
*/
|
|
79
|
+
function launchCommand(program, args) {
|
|
80
|
+
const line = typeof program === 'string' ? program.trim() : '';
|
|
81
|
+
if (!args.length && /\s/.test(line) && !existsSync(line)) {
|
|
82
|
+
return process.platform === 'win32'
|
|
83
|
+
? { program: 'powershell.exe', arguments: ['-NoProfile', '-Command', line] }
|
|
84
|
+
: { program: process.env.SHELL || '/bin/sh', arguments: ['-lc', line] };
|
|
85
|
+
}
|
|
86
|
+
return { program, arguments: args };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How a pane that is gone ended, from the pane-exit log its tmux server's hooks write (under SUPERCODE_HOME, or the
|
|
91
|
+
* user's ~/.config/supercode when the server's environment has none): { failed, how } or null when nothing is
|
|
92
|
+
* recorded. A pane that failed has a `died` line with its status or signal; one that ended cleanly only `closed`.
|
|
93
|
+
*/
|
|
94
|
+
function paneExit(pane) {
|
|
95
|
+
const logs = [process.env.SUPERCODE_HOME, join(homedir(), '.config', 'supercode')].filter(Boolean).map((dir) => join(dir, 'pane-exits.log'));
|
|
96
|
+
for (const log of logs) {
|
|
97
|
+
let lines;
|
|
98
|
+
try { lines = readFileSync(log, 'utf8').split('\n'); } catch { continue; }
|
|
99
|
+
const died = lines.findLast((entry) => entry.includes(` ${pane} died `));
|
|
100
|
+
const match = died && /status=(\d*) signal=(\S*)/.exec(died);
|
|
101
|
+
if (match) return { failed: true, how: match[1] ? `status ${match[1]}` : match[2] ? `signal ${match[2]}` : 'failed' };
|
|
102
|
+
if (lines.some((entry) => entry.endsWith(` ${pane} closed`))) return { failed: false, how: 'status 0' };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
export class TeamsMachine extends MachineCore {
|
|
71
108
|
/**
|
|
72
109
|
* `link`: { server, teamId, machineId, credential, incarnationId, configuration } to hold the
|
|
73
110
|
* server link; `configuration()` resolves the machine's current configuration from the server
|
|
74
111
|
* (custodian and machine grants). Without it the daemon serves its local door only.
|
|
75
112
|
*/
|
|
76
|
-
constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null } = {}) {
|
|
113
|
+
constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null, popups = false } = {}) {
|
|
77
114
|
super({ home, floor, limits, env, link });
|
|
78
115
|
this.supercodeBin = supercode;
|
|
79
116
|
this.tmuxSocket = tmuxSocket ?? null;
|
|
117
|
+
// Each pane launched here opens a terminal window on this machine's screen (popupWindow).
|
|
118
|
+
this.popups = Boolean(popups);
|
|
80
119
|
this.launches = new Launches(this.p.launches);
|
|
81
120
|
// Windows has no tmux: its panes are ConPTY children of this daemon, and end with it.
|
|
82
121
|
if (process.platform === 'win32' && !terminal.PtyTerminalHost) throw new Error('panes on Windows need a newer @volter/supercode-terminal (one with PtyTerminalHost)');
|
|
@@ -627,6 +666,20 @@ export class TeamsMachine extends MachineCore {
|
|
|
627
666
|
|
|
628
667
|
listPorts() { return listAnnouncedPorts(this.portsDir); }
|
|
629
668
|
|
|
669
|
+
/**
|
|
670
|
+
* A terminal window on this machine's screen showing the pane, so the people at the machine see what runs on it:
|
|
671
|
+
* in whichever capable terminal is installed (popup-terminals.mjs), running an attach client. The pane ending ends
|
|
672
|
+
* the client and the window closes; a person closing the window ends only the client, never the pane.
|
|
673
|
+
*/
|
|
674
|
+
#popupWindow(contextKey, session) {
|
|
675
|
+
const terminal = popupTerminal(this.env);
|
|
676
|
+
if (!terminal) return;
|
|
677
|
+
const attach = process.platform === 'win32'
|
|
678
|
+
? [process.execPath, fileURLToPath(new URL('./bin/fleet.mjs', import.meta.url)), 'attach', contextKey, '--mode', 'control']
|
|
679
|
+
: [spawnSync('which', ['tmux'], { encoding: 'utf8' }).stdout.trim() || 'tmux', ...(this.tmuxSocket ? ['-L', this.tmuxSocket] : []), 'attach-session', '-t', session.label ?? contextKey];
|
|
680
|
+
openPopup(terminal, attach, { ...this.env, TMUX: '' });
|
|
681
|
+
}
|
|
682
|
+
|
|
630
683
|
/**
|
|
631
684
|
* Rename the tmux window behind an owned pane. The terminal host exposes
|
|
632
685
|
* no rename, so this is `tmux` on the host's own socket; automatic-rename is
|
|
@@ -939,7 +992,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
939
992
|
let session;
|
|
940
993
|
try {
|
|
941
994
|
session = await this.host.createSession({
|
|
942
|
-
command:
|
|
995
|
+
command: launchCommand(params.program, params.arguments ?? []),
|
|
943
996
|
contextKey,
|
|
944
997
|
cwd: params.cwd,
|
|
945
998
|
env: this.#paneEnv(contextKey, params.env),
|
|
@@ -961,6 +1014,17 @@ export class TeamsMachine extends MachineCore {
|
|
|
961
1014
|
// answers "did my message go in, and was it late?" without anyone
|
|
962
1015
|
// reading the screen.
|
|
963
1016
|
if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, input: params.initialInput, act });
|
|
1017
|
+
// A program that ends at once (not found, not runnable, failing on its first line) takes its pane with it:
|
|
1018
|
+
// the caller is told, with its exit status, instead of being handed a pane that is already gone.
|
|
1019
|
+
await new Promise((resolve) => setTimeout(resolve, LAUNCH_SETTLE_MS));
|
|
1020
|
+
if (!await this.#paneByContext(contextKey)) {
|
|
1021
|
+
// Finished (it ran to a clean end) is an answer; exited (it failed at once) is the caller's error.
|
|
1022
|
+
const exit = paneExit(contextKey);
|
|
1023
|
+
const status = exit && !exit.failed ? 'finished' : 'exited';
|
|
1024
|
+
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status, error: exit?.failed ? exit.how : undefined });
|
|
1025
|
+
return { key, pane: contextKey, reused: false, status, exit: exit?.how ?? null };
|
|
1026
|
+
}
|
|
1027
|
+
if (this.popups && params.popup !== false) this.#popupWindow(contextKey, session);
|
|
964
1028
|
return { key, pane: contextKey, reused: false, status: 'ready' };
|
|
965
1029
|
}
|
|
966
1030
|
case 'launches.get': {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.36",
|
|
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
|
+
}
|