@volter/supercode-teams 0.2.100 → 0.2.102
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 +19 -0
- package/bin/teams.mjs +25 -6
- package/machine.mjs +33 -12
- package/package.json +1 -1
package/bin/fleet.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// `--on` names an enrolled machine of the selected context, reached through
|
|
17
17
|
// its Teams server. Omitted, every verb acts on this OS user's own daemon, so
|
|
18
18
|
// the remote arm and the local arm are one code path.
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
19
20
|
import { attachPane, attachRuntime } from '../client.mjs';
|
|
20
21
|
import { openOn, harnessRequest, nodeRequest, fleetDiscover } from '../fleet.mjs';
|
|
21
22
|
|
|
@@ -45,6 +46,20 @@ function channelForOn() {
|
|
|
45
46
|
return openOn(onName(), { context: contextName() });
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// Who starts a pane: supercode names it when it runs this CLI (empty: a person); run on its own, supercode is asked
|
|
50
|
+
// (`teams caller`), and a launch no supercode answers for records no starter (so no session may act on the pane).
|
|
51
|
+
let launcherAnswer;
|
|
52
|
+
function launcher() {
|
|
53
|
+
if (launcherAnswer === undefined) {
|
|
54
|
+
if (process.env.SUPERCODE_CALLER !== undefined) launcherAnswer = process.env.SUPERCODE_CALLER || null;
|
|
55
|
+
else {
|
|
56
|
+
const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['teams', 'caller'], { encoding: 'utf8', shell: process.platform === 'win32', windowsHide: true });
|
|
57
|
+
try { launcherAnswer = JSON.parse(r.stdout.trim().split('\n').pop()).caller ?? null; } catch { launcherAnswer = null; }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return launcherAnswer;
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
async function main() {
|
|
49
64
|
switch (verb) {
|
|
50
65
|
case 'discover': {
|
|
@@ -85,6 +100,10 @@ async function main() {
|
|
|
85
100
|
name: flags.name,
|
|
86
101
|
// `open` attaching this terminal to the pane itself asks the machine for no window of its own.
|
|
87
102
|
...(flags.popup === 'false' ? { popup: false } : {}),
|
|
103
|
+
// Who starts the pane (the session or board supercode resolved; unset for a person), and whether the pane
|
|
104
|
+
// runs its one verb as that caller (`close`/`stop` on another machine).
|
|
105
|
+
...(launcher() ? { caller: launcher() } : {}),
|
|
106
|
+
...(flags['act-for-caller'] ? { actForCaller: true } : {}),
|
|
88
107
|
// `open --notify-when-ended`: the launching session's address, told once when the pane ends, and the
|
|
89
108
|
// machine's mail name the notice comes from.
|
|
90
109
|
...(typeof flags.notify === 'string' ? { notify: { to: flags.notify, machine: String(flags['notify-machine'] ?? flags.on ?? '') } } : {}),
|
package/bin/teams.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// members, groups, sources, sync, machines, sessions) is the Teams workspace
|
|
27
27
|
// CLI in ./workspace.mjs.
|
|
28
28
|
import { createWriteStream, readFileSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
29
30
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
30
31
|
|
|
31
32
|
// A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
|
|
@@ -106,6 +107,24 @@ async function machineChannel() {
|
|
|
106
107
|
return openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home });
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
// The session (or board) this command acts for: the pane doors refuse a session acting on a pane it did not start.
|
|
111
|
+
// supercode names it when it runs this CLI (empty: a person); run on its own (`supercode-teams`), this CLI asks
|
|
112
|
+
// supercode (`teams caller`), which reads the session from this process's ancestry, and refuses when none answers.
|
|
113
|
+
let callerAnswer;
|
|
114
|
+
function caller() {
|
|
115
|
+
if (callerAnswer === undefined) {
|
|
116
|
+
if (process.env.SUPERCODE_CALLER !== undefined) callerAnswer = process.env.SUPERCODE_CALLER || null;
|
|
117
|
+
else {
|
|
118
|
+
const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['teams', 'caller'], { encoding: 'utf8', shell: process.platform === 'win32', windowsHide: true });
|
|
119
|
+
let answer;
|
|
120
|
+
try { answer = JSON.parse(r.stdout.trim().split('\n').pop()); } catch { /* no answer */ }
|
|
121
|
+
if (!answer || !('caller' in answer)) die(`cannot tell which session runs this, so the pane doors cannot hold it to the panes it started; nothing was done. Run it as \`supercode teams …\` (${(r.error?.message ?? r.stderr ?? '').trim().slice(0, 200)})`, 1);
|
|
122
|
+
callerAnswer = answer.caller ?? null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return callerAnswer ?? undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
109
128
|
async function withNode(fn) {
|
|
110
129
|
const channel = await machineChannel();
|
|
111
130
|
try {
|
|
@@ -149,7 +168,7 @@ async function main() {
|
|
|
149
168
|
case 'panes': {
|
|
150
169
|
if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('panes.list')));
|
|
151
170
|
if (sub === 'capture') return withNode(async (rpc) => out((await rpc.call('panes.capture', { pane: rest[0], lines: Number(flags.lines ?? 200) })).text));
|
|
152
|
-
if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0] })));
|
|
171
|
+
if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0], caller: caller() })));
|
|
153
172
|
die('panes ls|capture|close');
|
|
154
173
|
}
|
|
155
174
|
case 'title': {
|
|
@@ -173,9 +192,9 @@ async function main() {
|
|
|
173
192
|
die('attention ls | rank | request <pane> [--urgency now|soon|whenever] [--reason text] | clear <pane>');
|
|
174
193
|
}
|
|
175
194
|
case 'suspend':
|
|
176
|
-
return withNode(async (rpc) => out(await rpc.call('panes.suspend', { pane: sub, interrupt: Boolean(flags.interrupt) })));
|
|
195
|
+
return withNode(async (rpc) => out(await rpc.call('panes.suspend', { pane: sub, interrupt: Boolean(flags.interrupt), caller: caller() })));
|
|
177
196
|
case 'resume':
|
|
178
|
-
return withNode(async (rpc) => out(await rpc.call('panes.resume', { pane: sub, input: flags.input ? String(flags.input) : null })));
|
|
197
|
+
return withNode(async (rpc) => out(await rpc.call('panes.resume', { pane: sub, input: flags.input ? String(flags.input) : null, caller: caller() })));
|
|
179
198
|
case 'files': {
|
|
180
199
|
const path = rest[0];
|
|
181
200
|
if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('files.list', { path })));
|
|
@@ -214,10 +233,10 @@ async function main() {
|
|
|
214
233
|
// (answers `delivered: false` with the reason when it holds a draft).
|
|
215
234
|
if (flags.key !== undefined) {
|
|
216
235
|
if (flags.key === true || rest.length) die('input <pane> --key NAMES: keys by tmux name, space- or comma-separated (Escape, Enter, Tab, BTab, BSpace, Space, Up, Down, Left, Right, Home, End, PPage, NPage, DC, C-a … C-z)');
|
|
217
|
-
return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: keyBytes(String(flags.key)), submit: false, keys: true })));
|
|
236
|
+
return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: keyBytes(String(flags.key)), submit: false, keys: true, caller: caller() })));
|
|
218
237
|
}
|
|
219
|
-
if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' ') })));
|
|
220
|
-
return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'] })));
|
|
238
|
+
if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' '), caller: caller() })));
|
|
239
|
+
return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'], caller: caller() })));
|
|
221
240
|
case 'port': {
|
|
222
241
|
// `port <pane>/<n> [--listen host:port]`: a machine port forwarded here. Stays up until ctrl-c.
|
|
223
242
|
const spec = String(sub ?? '');
|
package/machine.mjs
CHANGED
|
@@ -577,6 +577,19 @@ export class TeamsMachine extends MachineCore {
|
|
|
577
577
|
return env;
|
|
578
578
|
}
|
|
579
579
|
|
|
580
|
+
/**
|
|
581
|
+
* A session acts only on the panes it started: input, a turn, a suspend or a close from a session (`caller`, its
|
|
582
|
+
* address) on a pane another session, a board or a person started is refused, naming who started it. A person (no
|
|
583
|
+
* caller) and a board (its cards' own door) act on any pane.
|
|
584
|
+
*/
|
|
585
|
+
#refuseForeign(pane, caller) {
|
|
586
|
+
if (typeof caller !== 'string' || !caller || /^sc:[^:]+:board:/.test(caller)) return;
|
|
587
|
+
const by = this.launches.all().find((entry) => entry.pane === pane)?.by ?? null;
|
|
588
|
+
if (by === caller) return;
|
|
589
|
+
const starter = by ? `was started by ${by}` : 'was not started by a session (a person or an earlier daemon started it)';
|
|
590
|
+
throw Object.assign(new Error(`pane ${pane} ${starter}, not by ${caller}: a session acts only on the panes it started. Nothing was done; reach that session through its own doors (supercode message send)`), { code: 403 });
|
|
591
|
+
}
|
|
592
|
+
|
|
580
593
|
async #paneByContext(contextKey) {
|
|
581
594
|
const sessions = await this.host.listSessions();
|
|
582
595
|
return sessions.find((s) => s.contextKey === contextKey) ?? null;
|
|
@@ -701,13 +714,13 @@ export class TeamsMachine extends MachineCore {
|
|
|
701
714
|
* Never rejects. The only caller is a fire-and-forget from the request path,
|
|
702
715
|
* where an unhandled rejection would take the daemon down over one pane.
|
|
703
716
|
*/
|
|
704
|
-
#deliverInitialInput({ key, contextKey, session, kind, input, act }) {
|
|
717
|
+
#deliverInitialInput({ key, contextKey, session, kind, by, input, act }) {
|
|
705
718
|
const abort = new AbortController();
|
|
706
719
|
const task = (async () => {
|
|
707
720
|
const readyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal);
|
|
708
721
|
if (readyBy === 'aborted') return;
|
|
709
722
|
if (readyBy === 'pane-gone') {
|
|
710
|
-
this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
|
|
723
|
+
this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
|
|
711
724
|
act('launch', `pane:${contextKey}`, 'failed', 'first input undelivered: the pane ended before it was ready');
|
|
712
725
|
return;
|
|
713
726
|
}
|
|
@@ -719,7 +732,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
719
732
|
try {
|
|
720
733
|
await this.host.sendInput(session.id, submission(input), { submitMode: 'single' });
|
|
721
734
|
} catch (error) {
|
|
722
|
-
this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputReadyBy: readyBy, inputError: error.message });
|
|
735
|
+
this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy, inputError: error.message });
|
|
723
736
|
act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${error.code ?? error.message}`);
|
|
724
737
|
return;
|
|
725
738
|
}
|
|
@@ -728,12 +741,12 @@ export class TeamsMachine extends MachineCore {
|
|
|
728
741
|
// vouched for. A launch that reads `ready` without it was given its
|
|
729
742
|
// message by an input box that existed.
|
|
730
743
|
const late = readyBy === 'deadline';
|
|
731
|
-
this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: true, inputReadyBy: readyBy, ...(late ? { inputDeliveredLate: true } : {}) });
|
|
744
|
+
this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: true, inputReadyBy: readyBy, ...(late ? { inputDeliveredLate: true } : {}) });
|
|
732
745
|
act('launch', `pane:${contextKey}`, 'ok', `first input delivered (${readyBy})`);
|
|
733
746
|
})().catch((error) => {
|
|
734
747
|
// Nothing above is expected to throw; if something does, the launch record
|
|
735
748
|
// is where it is answerable, not a crashed daemon.
|
|
736
|
-
this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputError: error?.message ?? String(error) });
|
|
749
|
+
this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputError: error?.message ?? String(error) });
|
|
737
750
|
}).finally(() => {
|
|
738
751
|
if (this.pendingInitialInputs.get(key)?.task === task) this.pendingInitialInputs.delete(key);
|
|
739
752
|
});
|
|
@@ -1140,10 +1153,13 @@ export class TeamsMachine extends MachineCore {
|
|
|
1140
1153
|
return { key, pane: existing.pane, reused: true, status: existing.status };
|
|
1141
1154
|
}
|
|
1142
1155
|
const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
|
|
1156
|
+
// Who started the pane: the launching session's (or board's) address, null for a person. A session's later
|
|
1157
|
+
// input and close on the pane are refused unless it is this one (#refuseForeign).
|
|
1158
|
+
const by = typeof params.caller === 'string' && params.caller ? params.caller : null;
|
|
1143
1159
|
// Who to tell when the pane ends (`open --new … --notify-when-ended`): a session's mail address, and this
|
|
1144
1160
|
// machine's name as the address a notice from its pane comes from.
|
|
1145
1161
|
if (params.notify !== undefined && !(params.notify && typeof params.notify.to === 'string' && /^sc:[^:\s]+:[^:\s]+:\S+$/.test(params.notify.to) && typeof params.notify.machine === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(params.notify.machine))) throw Object.assign(new Error('launch notify is { to: <sc: address>, machine: <this machine\'s mail name> }'), { code: 400 });
|
|
1146
|
-
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'issued' });
|
|
1162
|
+
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'issued' });
|
|
1147
1163
|
if (params.kind === 'claude-code' && params.cwd && (params.arguments ?? []).includes('--dangerously-skip-permissions')) trustClaudeFolder(params.cwd);
|
|
1148
1164
|
let session;
|
|
1149
1165
|
try {
|
|
@@ -1151,17 +1167,18 @@ export class TeamsMachine extends MachineCore {
|
|
|
1151
1167
|
command: launchCommand(params.program, params.arguments ?? []),
|
|
1152
1168
|
contextKey,
|
|
1153
1169
|
cwd: params.cwd,
|
|
1154
|
-
|
|
1170
|
+
// A pane that runs one verb for its launcher (`close`/`stop` on another machine) acts as that launcher.
|
|
1171
|
+
env: this.#paneEnv(contextKey, params.actForCaller && by ? { ...params.env, SUPERCODE_CALLER: by } : params.env),
|
|
1155
1172
|
// The pane's own tmux session is always its key: the exit hooks record and clean up p_* sessions only, and
|
|
1156
1173
|
// a pane named otherwise lingered dead with its exit status lost. A launch's name is its title.
|
|
1157
1174
|
name: contextKey,
|
|
1158
1175
|
});
|
|
1159
1176
|
} catch (error) {
|
|
1160
|
-
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'failed', error: error.message });
|
|
1177
|
+
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'failed', error: error.message });
|
|
1161
1178
|
act('launch', `pane:${contextKey}`, 'failed', error.code ?? error.message);
|
|
1162
1179
|
throw error;
|
|
1163
1180
|
}
|
|
1164
|
-
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'ready' });
|
|
1181
|
+
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'ready' });
|
|
1165
1182
|
act('launch', `pane:${contextKey}`, 'ok', params.kind ?? params.program);
|
|
1166
1183
|
if (typeof params.name === 'string' && params.name.trim()) {
|
|
1167
1184
|
const titles = readJson(this.p.titles, { v: 1, titles: {} });
|
|
@@ -1177,7 +1194,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1177
1194
|
// outcome is recorded against the launch key, so `launches.get|list`
|
|
1178
1195
|
// answers "did my message go in, and was it late?" without anyone
|
|
1179
1196
|
// reading the screen.
|
|
1180
|
-
if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, input: params.initialInput, act });
|
|
1197
|
+
if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, by, input: params.initialInput, act });
|
|
1181
1198
|
// A program that ends at once (not found, not runnable, failing on its first line) takes its pane with it:
|
|
1182
1199
|
// the caller is told, with its exit status, instead of being handed a pane that is already gone.
|
|
1183
1200
|
await new Promise((resolve) => setTimeout(resolve, LAUNCH_SETTLE_MS));
|
|
@@ -1186,7 +1203,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1186
1203
|
// Finished (it ran to a clean end) is an answer; exited (it failed at once) is the caller's error.
|
|
1187
1204
|
const exit = settled?.dead ? deadPaneExit(settled) : paneExit(contextKey);
|
|
1188
1205
|
const status = exit && !exit.failed ? 'finished' : 'exited';
|
|
1189
|
-
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status, error: exit?.failed ? exit.how : undefined });
|
|
1206
|
+
this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status, error: exit?.failed ? exit.how : undefined });
|
|
1190
1207
|
return { key, pane: contextKey, reused: false, status, exit: exit?.how ?? null };
|
|
1191
1208
|
}
|
|
1192
1209
|
if (this.popups && params.popup !== false) this.#popupWindow(contextKey, session);
|
|
@@ -1210,12 +1227,13 @@ export class TeamsMachine extends MachineCore {
|
|
|
1210
1227
|
const rows = [];
|
|
1211
1228
|
for (const found of this.launches.all()) {
|
|
1212
1229
|
const session = found.pane ? await this.#paneByContext(found.pane) : null;
|
|
1213
|
-
rows.push({ key: found.key, pane: found.pane ?? null, kind: found.kind ?? null, status: found.status ?? null, at: found.t, live: Boolean(session), ...initialInputFacts(found) });
|
|
1230
|
+
rows.push({ key: found.key, pane: found.pane ?? null, kind: found.kind ?? null, by: found.by ?? null, status: found.status ?? null, at: found.t, live: Boolean(session), ...initialInputFacts(found) });
|
|
1214
1231
|
}
|
|
1215
1232
|
return rows;
|
|
1216
1233
|
}
|
|
1217
1234
|
case 'panes.close': {
|
|
1218
1235
|
need(operator ? 'control' : 'control', `pane:${params.pane}`);
|
|
1236
|
+
this.#refuseForeign(params.pane, params.caller);
|
|
1219
1237
|
const session = await this.#paneByContext(params.pane);
|
|
1220
1238
|
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
1221
1239
|
await this.host.closeSession(session.id);
|
|
@@ -1228,6 +1246,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1228
1246
|
// working, only `{input}` is refused — until `resume`, which may carry
|
|
1229
1247
|
// the input that hands the pane back. Local tmux input is never touched.
|
|
1230
1248
|
need('control', `pane:${params.pane}`);
|
|
1249
|
+
this.#refuseForeign(params.pane, params.caller);
|
|
1231
1250
|
const session = await this.#paneByContext(params.pane);
|
|
1232
1251
|
if (!session) throw new Error(`no pane with context ${params.pane}`);
|
|
1233
1252
|
const doc = readJson(this.p.suspended, { v: 1, panes: {} });
|
|
@@ -1249,6 +1268,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1249
1268
|
// mechanics) for callers that are services rather than terminals. Same gate as an
|
|
1250
1269
|
// attachment in control mode, same suspend rule as remote input everywhere.
|
|
1251
1270
|
need('control', `pane:${params.pane}`);
|
|
1271
|
+
this.#refuseForeign(params.pane, params.caller);
|
|
1252
1272
|
const session = await this.#paneByContext(params.pane);
|
|
1253
1273
|
if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
|
|
1254
1274
|
if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
|
|
@@ -1267,6 +1287,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1267
1287
|
// answer, not an error: the caller keeps the turn and asks again. Its own
|
|
1268
1288
|
// method, so a daemon without it refuses instead of typing into a draft.
|
|
1269
1289
|
need('control', `pane:${params.pane}`);
|
|
1290
|
+
this.#refuseForeign(params.pane, params.caller);
|
|
1270
1291
|
const session = await this.#paneByContext(params.pane);
|
|
1271
1292
|
if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
|
|
1272
1293
|
if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.102",
|
|
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",
|