@volter/supercode-teams 0.2.20 → 0.2.21

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/README.md CHANGED
@@ -29,7 +29,8 @@ The daemon owns one `supercode harness serve` and the tmux terminal host.
29
29
  Two doors reach it:
30
30
 
31
31
  - **Local**: an owner-only Unix socket, `$SUPERCODE_HOME/teams/machine.sock` (or, when that path
32
- is too long for a socket address, a per-user 0700 directory under `/tmp`). Whoever can open it
32
+ is too long for a socket address, a per-user 0700 directory under `/tmp`). On Windows it is a
33
+ named pipe, `\\.\pipe\supercode-<hash>`, which by default only its owner may open. Whoever can open it
33
34
  is this OS user, an operator. The local CLI, the agent inside the machine and a service on the
34
35
  same box (RH2's connector) use it.
35
36
  - **Remote**: the Teams server. The daemon holds one outbound WebSocket link; the server forwards
package/bin/teams.mjs CHANGED
@@ -25,8 +25,16 @@
25
25
  // Every other verb (server, login, context, connect, disconnect, status,
26
26
  // members, groups, sources, sync, machines, sessions) is the Teams workspace
27
27
  // CLI in ./workspace.mjs.
28
- import { readFileSync, writeFileSync } from 'node:fs';
28
+ import { createWriteStream, readFileSync, writeFileSync } from 'node:fs';
29
29
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
30
+
31
+ // A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
32
+ if (process.env.SUPERCODE_TEAMS_LOG) {
33
+ const log = createWriteStream(process.env.SUPERCODE_TEAMS_LOG, { flags: 'a' });
34
+ for (const stream of [process.stdout, process.stderr]) stream.write = log.write.bind(log);
35
+ // The log is this process's own; a pane or harness it starts writes to its own terminal.
36
+ delete process.env.SUPERCODE_TEAMS_LOG;
37
+ }
30
38
  import { TeamsMachine } from '../machine.mjs';
31
39
  import { harnessTransport, nodeRpc, forwardPort, watchMachine } from '../client.mjs';
32
40
  import { attachBrowserProfile, listBrowserProfiles } from '../browser-attach.mjs';
@@ -63,20 +71,23 @@ if (verb === undefined || verb === 'help') {
63
71
  await workspaceMain(['--help']);
64
72
  process.exit(0);
65
73
  }
66
- if (!MACHINE_VERBS.has(verb)) {
74
+ // A workspace verb is done when its promise settles. On Windows the process then ends on its own: Node there aborts
75
+ // (libuv's `UV_HANDLE_CLOSING` assertion) when process.exit() follows a fetch, which every workspace verb makes.
76
+ const workspaceVerb = !MACHINE_VERBS.has(verb);
77
+ if (workspaceVerb) {
67
78
  const { workspaceMain } = await import('./workspace.mjs');
68
- await workspaceMain(argv).catch((error) => {
79
+ await workspaceMain(argv).then(() => { process.exitCode = 0; }, (error) => {
69
80
  process.stderr.write(JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } }) + '\n');
70
- process.exit(error.exitCode ?? ({ invalid_request: 2, ambiguous_reference: 2, authentication_required: 3, not_found: 4, forbidden: 4, unsupported: 5, machine_offline: 5, controller_busy: 6, conflict: 6, resync_required: 6, revision_mismatch: 6, idempotency_conflict: 6, batch_gap: 6, capture_conflict: 6, source_paused: 6 }[error.code] ?? 1));
81
+ process.exitCode = (error.exitCode ?? ({ invalid_request: 2, ambiguous_reference: 2, authentication_required: 3, not_found: 4, forbidden: 4, unsupported: 5, machine_offline: 5, controller_busy: 6, conflict: 6, resync_required: 6, revision_mismatch: 6, idempotency_conflict: 6, batch_gap: 6, capture_conflict: 6, source_paused: 6 }[error.code] ?? 1));
71
82
  });
72
- process.exit(0);
83
+ if (process.platform !== 'win32') process.exit();
73
84
  }
74
85
 
75
86
  const home = flags.home ? String(flags.home) : teamsHome();
76
87
  const p = paths(home);
77
88
  const out = (value) => process.stdout.write((typeof value === 'string' ? value : JSON.stringify(value, null, 2)) + '\n');
78
89
  const die = (message, code = 2) => { process.stderr.write(`supercode teams: ${message}\n`); process.exit(code); };
79
- for (const flag of Object.keys(flags)) if (!KNOWN_FLAGS.has(flag)) die(`unknown flag: --${flag}`);
90
+ if (!workspaceVerb) for (const flag of Object.keys(flags)) if (!KNOWN_FLAGS.has(flag)) die(`unknown flag: --${flag}`);
80
91
 
81
92
  async function machineChannel() {
82
93
  return openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home });
@@ -288,4 +299,4 @@ async function main() {
288
299
  }
289
300
  }
290
301
 
291
- main().catch((error) => die(error.message ?? String(error), error.code === 'machine_offline' ? 5 : 1));
302
+ if (!workspaceVerb) main().catch((error) => die(error.message ?? String(error), error.code === 'machine_offline' ? 5 : 1));
@@ -1,7 +1,7 @@
1
1
  const BOOLEANS = new Set([
2
2
  'json', 'help', 'live', 'saved', 'watch', 'paginate', 'token-stdin',
3
3
  'invite-stdin', 'include-children', 'rotate', 'install', 'force',
4
- 'take-control', 'dry-run', 'volter', 'foreground',
4
+ 'take-control', 'dry-run', 'volter', 'foreground', 'no-connect',
5
5
  ]);
6
6
 
7
7
  const VALUES = new Set([
@@ -2,19 +2,30 @@ import { createConnection, createServer } from 'node:net';
2
2
  import { mkdirSync, renameSync, rmSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { randomBytes } from 'node:crypto';
5
- import { shortSocketPath, ensureSocketDirectory } from '@volter/teams/home';
5
+ import { shortSocketPath, ensureSocketDirectory, isNamedPipe } from '@volter/teams/home';
6
6
  import { execFileSync } from 'node:child_process';
7
7
 
8
8
  const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
9
+ // A process's start time names it exactly: a reused pid has another. Windows has no ps; its process table answers.
9
10
  const processIdentity = pid => {
10
- try { return execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 1_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; }
11
+ const [command, args] = process.platform === 'win32'
12
+ ? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)}).StartTime.ToUniversalTime().Ticks`]]
13
+ : ['/bin/ps', ['-o', 'lstart=', '-p', String(pid)]];
14
+ try { return execFileSync(command, args, { encoding: 'utf8', timeout: process.platform === 'win32' ? 5_000 : 1_000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim() || null; }
11
15
  catch { return null; }
12
16
  };
13
17
  const readOwner = lock => {
14
18
  try { return JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')); }
15
19
  catch { return null; }
16
20
  };
17
- const sameOwner = owner => owner?.pid > 0 && owner.start === processIdentity(owner.pid);
21
+ // Without both start times (a lookup that timed out, as a cold PowerShell can) the pid's liveness decides, so a live
22
+ // owner is never taken for stale.
23
+ const sameOwner = owner => {
24
+ if (!(owner?.pid > 0)) return false;
25
+ const start = processIdentity(owner.pid);
26
+ if (owner.start != null && start != null) return owner.start === start;
27
+ try { process.kill(owner.pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
28
+ };
18
29
 
19
30
  export async function activeSocket(path) {
20
31
  return new Promise(resolve => {
@@ -65,15 +76,19 @@ export async function acquireLifecycle(directory, onStop) {
65
76
  catch (renameError) { if (renameError.code !== 'ENOENT') throw renameError; }
66
77
  }
67
78
  }
68
- try { unlinkSync(socketPath); } catch (error) { if (error.code !== 'ENOENT') throw error; }
79
+ if (!isNamedPipe(socketPath)) try { unlinkSync(socketPath); } catch (error) { if (error.code !== 'ENOENT') throw error; }
69
80
  const server = createServer(connection => {
70
- let body = ''; connection.setEncoding('utf8');
81
+ let body = '', answered = false; connection.setEncoding('utf8');
71
82
  connection.on('error', () => {});
72
- connection.on('data', chunk => { body += chunk; if (body.length > 1024) connection.destroy(); });
73
- connection.on('end', () => {
83
+ // A request is one line; it is answered at its newline, or at the end of a request without one. A Windows named
84
+ // pipe has no half-close, so a client there cannot end its side and still read the answer.
85
+ const answer = () => {
86
+ if (answered) return; answered = true;
74
87
  if (body.trim() !== token) { connection.end('forbidden\n'); return; }
75
88
  connection.end('ok\n', () => { void onStop(); });
76
- });
89
+ };
90
+ connection.on('data', chunk => { body += chunk; if (body.length > 1024) connection.destroy(); else if (body.includes('\n')) answer(); });
91
+ connection.on('end', answer);
77
92
  });
78
93
  try {
79
94
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
@@ -87,7 +102,7 @@ export async function acquireLifecycle(directory, onStop) {
87
102
  close: async () => {
88
103
  await new Promise(resolve => server.close(resolve));
89
104
  if (readOwner(lock)?.token !== token) return;
90
- try { unlinkSync(socketPath); } catch {}
105
+ if (!isNamedPipe(socketPath)) try { unlinkSync(socketPath); } catch {}
91
106
  rmSync(lock, { recursive: true, force: true });
92
107
  },
93
108
  };
@@ -101,7 +116,7 @@ export async function requestStop(socketPath, token) {
101
116
  const finish = (error, value) => { if (settled) return; settled = true; socket.destroy(); error ? reject(error) : resolve(value); };
102
117
  socket.setTimeout(500, () => finish(new Error('connector control request timed out')));
103
118
  socket.setEncoding('utf8');
104
- socket.once('connect', () => socket.end(`${token}\n`));
119
+ socket.once('connect', () => process.platform === 'win32' ? socket.write(`${token}\n`) : socket.end(`${token}\n`));
105
120
  socket.on('data', chunk => { text += chunk; if (text.length > 1024) finish(new Error('connector control reply is too large')); });
106
121
  socket.once('end', () => finish(null, text));
107
122
  socket.once('error', error => finish(error));
package/bin/workspace.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { hostname } from 'node:os';
3
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
4
4
  import { basename, join, resolve } from 'node:path';
5
5
  import { execFileSync, spawn } from 'node:child_process';
6
6
  import { createHash } from 'node:crypto';
@@ -19,6 +19,8 @@ import { reserveArtifactDirectory, reserveProtected, writeProtected } from './wo
19
19
  import { acquireLifecycle, activeSocket, requestStop } from './workspace/lifecycle.mjs';
20
20
  import { volterDeviceSignIn } from './workspace/volter-device.mjs';
21
21
  import { loginSummary, signInToTeams } from './workspace/volter-login.mjs';
22
+ import { readSharedSignIn, signOut } from '@volter/identity';
23
+ import { createInterface } from 'node:readline/promises';
22
24
 
23
25
  const ERROR_EXITS = { invalid_request: 2, ambiguous_reference: 2, authentication_required: 3, not_found: 4, forbidden: 4, unsupported: 5, machine_offline: 5, controller_busy: 6, conflict: 6, resync_required: 6, revision_mismatch: 6, idempotency_conflict: 6, batch_gap: 6, capture_conflict: 6, source_paused: 6 };
24
26
 
@@ -35,6 +37,7 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
35
37
  if (verb === 'context') return contextCommand({ contexts, flags, sub, rest, output });
36
38
  if (verb === 'server') return serverCommand({ flags, sub, rest, home, output });
37
39
  if (verb === 'login') return loginCommand({ flags, origin: sub, rest, home, output });
40
+ if (verb === 'auth') return authCommand({ flags, sub, rest, home, contexts, output, env, dependencies });
38
41
  const explicitContext = flags.context ?? env.SUPERCODE_CONTEXT;
39
42
  let selected = explicitContext ?? contexts.read().current;
40
43
  const urlReference = verb === 'sessions' ? rest.find(value => /^https?:\/\//.test(value)) : null;
@@ -145,6 +148,112 @@ async function loginCommand({ flags, origin, rest, home, output }) {
145
148
  return signedIn(flags, output, await loginContext({ supercodeHome: home, name: flags.as ?? null, origin, teamId: flags.team ?? null, token, invite, as: flags.as ?? null, fetch: globalThis.fetch }));
146
149
  }
147
150
 
151
+ /**
152
+ * `supercode auth`: one Volter sign-in for everything supercode does, the way `gh auth` signs in to GitHub.
153
+ * login [ORIGIN] sign in (or say who is), make that team current, and offer to connect this machine
154
+ * status who is signed in, which team, and whether this machine is connected
155
+ * switch [NAME] make another saved team current (without NAME, list them)
156
+ * logout forget the sign-in and every team it signed into (refused while this machine is connected)
157
+ * Everything a person reads goes to stdout: none of it is an error.
158
+ */
159
+ async function authCommand({ flags, sub = 'status', rest, home, contexts, output, env }) {
160
+ // A person reads stdout; with --json it carries only the JSON, and progress goes to stderr.
161
+ const say = line => (flags.json ? process.stderr : process.stdout).write(`${line}\n`);
162
+ if (sub === 'login') {
163
+ only(flags, ['json', 'help', 'team', 'as', 'force', 'no_connect', 'cwd', 'volter_issuer']);
164
+ requireArgs(rest, 0, 1, 'auth login [ORIGIN] [--team TEAM] [--as NAME] [--force] [--no-connect] [--cwd DIR]');
165
+ const origin = rest[0] ?? env.SUPERCODE_TEAMS_ORIGIN ?? DEFAULT_TEAMS_ORIGIN;
166
+ let account = authAccount(home, contexts);
167
+ if (account.context && !flags.force && !flags.team && !rest[0]) {
168
+ if (flags.json) return output(account);
169
+ say(`✓ Already logged in to Volter as ${account.email ?? 'you'} · team ${account.team}`);
170
+ } else {
171
+ const signIn = { ...(flags.volter_issuer ? { issuer: flags.volter_issuer } : {}), audience: new URL(origin).origin, say };
172
+ // As `teams login`: --team signs into that team; otherwise the person chooses among the teams they may use
173
+ // (volter-login.mjs), and the context it leaves current is the one this login reports.
174
+ if (flags.team) {
175
+ const saved = await loginContext({ supercodeHome: home, name: flags.as ?? null, origin, teamId: flags.team, token: null, invite: null, identityToken: await volterDeviceSignIn(signIn), as: flags.as ?? null, fetch: globalThis.fetch });
176
+ const name = Object.entries(contexts.read().contexts).find(([, value]) => value.credential_ref === saved.credential_ref)?.[0];
177
+ if (name) contexts.use(name);
178
+ } else {
179
+ const signedInTo = await volterDeviceSignIn({ ...signIn, finish: ({ token, ask }) => signInToTeams({ supercodeHome: home, origin, identityToken: token, ask, as: flags.as ?? null }) });
180
+ for (const { team, reason } of signedInTo.failed ?? []) say(`Not signed in to ${team}: ${reason}`);
181
+ }
182
+ account = authAccount(home, contexts);
183
+ if (flags.json) return output(account);
184
+ say(`✓ Logged in to Volter as ${account.email ?? 'you'} · team ${account.team ?? 'none'}${account.others.length ? ` (also: ${account.others.join(', ')}; supercode auth switch)` : ''}`);
185
+ }
186
+ if (flags.no_connect || !account.context || !process.stdin.isTTY || !process.stdout.isTTY) return;
187
+ const command = authTeamCommand(home, account.context, flags, output, env);
188
+ if (command.state().read()?.machine) return;
189
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
190
+ let folder;
191
+ try {
192
+ const yes = (await readline.question(`Connect this machine to ${account.team}, so its sessions show there and teammates you allow can reach it? [Y/n] `)).trim();
193
+ if (/^n/i.test(yes)) { say(`Not connected. Later: supercode teams connect`); return; }
194
+ folder = (await readline.question(`Folder to share [${flags.cwd ?? process.cwd()}]: `)).trim() || (flags.cwd ?? process.cwd());
195
+ } finally { readline.close(); }
196
+ // As `teams connect` from a terminal: a service where the platform has a manager, else connected while it runs
197
+ // (the prompt is closed first, so Ctrl+C reaches the connector rather than the prompt).
198
+ command.flags = { cwd: folder };
199
+ return await connectorCommand('connect', command, home);
200
+ }
201
+ if (sub === 'status') {
202
+ only(flags, ['json', 'help']); requireArgs(rest, 0, 0, 'auth status');
203
+ const account = authAccount(home, contexts);
204
+ if (account.context) {
205
+ const command = authTeamCommand(home, account.context, flags, output, env);
206
+ const enrollment = command.state().read();
207
+ account.machine = enrollment?.machine ? { id: enrollment.machine.id, name: enrollment.machine.name, folder: enrollment.roots?.[0]?.path ?? null, service: enrollment.service?.kind ?? null, connection: null } : null;
208
+ try { await command.client.me(); account.reachable = true; } catch { account.reachable = false; }
209
+ if (account.machine && account.reachable) account.machine.connection = (await command.team.machines.get(account.machine.id).catch(() => null))?.data?.connection ?? null;
210
+ }
211
+ if (flags.json) return output(account);
212
+ say(account.email ? `Volter ✓ ${account.email}` : 'Volter not logged in (supercode login)');
213
+ if (!account.context) return say(account.others.length ? `Team none current · saved: ${account.others.join(', ')} (supercode auth switch NAME)` : 'Team none (supercode login)');
214
+ say(`Team ${account.team} · ${new URL(account.origin).host}${account.reachable ? '' : ' · unreachable'}${account.others.length ? ` · also: ${account.others.join(', ')} (supercode auth switch)` : ''}`);
215
+ say(account.machine ? `Machine ${account.machine.name} · ${account.machine.connection ?? 'unknown'} · sharing ${account.machine.folder ?? '?'}${account.machine.service ? ` (${account.machine.service})` : ' (runs while `teams connect` does)'}` : 'Machine not connected (supercode teams connect)');
216
+ return;
217
+ }
218
+ if (sub === 'switch') {
219
+ only(flags, ['json', 'help']); requireArgs(rest, 0, 1, 'auth switch [NAME]');
220
+ const doc = contexts.read(), names = Object.keys(doc.contexts);
221
+ if (!rest[0]) return flags.json ? output({ current: doc.current, contexts: names }) : say(names.map(name => `${name === doc.current ? '*' : ' '} ${name} · ${doc.contexts[name].team_name ?? doc.contexts[name].team_id}`).join('\n') || 'No saved teams (supercode login)');
222
+ contexts.use(rest[0]);
223
+ return say(`✓ Switched to ${doc.contexts[rest[0]]?.team_name ?? rest[0]}`);
224
+ }
225
+ if (sub === 'logout') {
226
+ only(flags, ['json', 'help']); requireArgs(rest, 0, 0, 'auth logout');
227
+ const account = authAccount(home, contexts), doc = contexts.read();
228
+ // One sign-in is one credential, shared by every team context it signed into, so logging out ends all of them.
229
+ const credentialRef = account.context ? doc.contexts[account.context]?.credential_ref : null;
230
+ const signedIn = Object.entries(doc.contexts).filter(([name, context]) => name === account.context || (credentialRef && context.credential_ref === credentialRef));
231
+ // A connected machine's connector runs as one of those contexts, so logging out would strand it: disconnect first.
232
+ const connected = signedIn.find(([, context]) => contexts.connector(context.server_id, context.team_id).read()?.machine);
233
+ if (connected) throw Object.assign(new Error(`this machine is connected to ${connected[1].team_name ?? connected[0]}; run supercode teams disconnect first, then log out`), { code: 'conflict', exitCode: 6 });
234
+ const signedOut = signOut();
235
+ for (const [name] of signedIn) contexts.remove(name);
236
+ if (credentialRef) rmSync(join(contexts.paths.credentials, `${credentialRef}.json`), { force: true });
237
+ const teams = signedIn.map(([name, context]) => context.team_name ?? name);
238
+ if (flags.json) return output({ signed_out: signedOut, removed_contexts: signedIn.map(([name]) => name) });
239
+ return say(signedOut || teams.length ? `✓ Logged out${account.email ? ` ${account.email}` : ''}${teams.length ? ` from ${teams.join(', ')}` : ''}; every Volter tool on this machine is signed out` : 'Not logged in');
240
+ }
241
+ usage('auth login|status|switch|logout');
242
+ }
243
+
244
+ /** Who is signed in here: the machine's shared Volter sign-in and the current team context. */
245
+ function authAccount(home, contexts) {
246
+ const doc = contexts.read(), name = doc.current && doc.current !== 'local' ? doc.current : null, context = name ? doc.contexts[name] : null;
247
+ const shared = readSharedSignIn();
248
+ return { email: shared?.email ?? null, context: context ? name : null, team: context?.team_name ?? context?.team_id ?? null, origin: context?.origin ?? null, serverId: context?.server_id ?? null, teamId: context?.team_id ?? null, others: Object.keys(doc.contexts).filter(other => other !== name) };
249
+ }
250
+
251
+ /** The command a team verb runs with, for the current team context. */
252
+ function authTeamCommand(home, contextName, flags, output, env) {
253
+ const { client, context } = fromContext({ supercodeHome: home, name: contextName, fetch: globalThis.fetch });
254
+ return { flags, rest: [], output, client, context, team: client.team(context.team_id), home, contextName, env, dependencies: {}, state: () => new WorkspaceContexts(home).connector(context.server_id, context.team_id) };
255
+ }
256
+
148
257
  /** What a login says: the context as JSON, or to a person, the team they are in and the next step. */
149
258
  function signedIn(flags, output, saved) {
150
259
  if (flags.json) return output(saved);
@@ -536,10 +645,18 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
536
645
  saved = state.update(current => ({ ...current, roots: [root] }));
537
646
  // Run from a terminal, connect installs the service that keeps this machine connected (launchd or systemd) and
538
647
  // returns; the service itself runs connect without a terminal, in the foreground. --foreground runs it here.
648
+ let service = null;
539
649
  if (flags.install || (!flags.foreground && process.stdin.isTTY && process.stdout.isTTY)) {
540
650
  const serviceBin = process.env.SUPERCODE_BIN;
541
651
  if (!serviceBin) unsupported('--install requires invocation through the native supercode CLI');
542
- const service = JSON.parse(execFileSync(serviceBin, ['__teams-connector-service', 'install', contextName, path, context.server_id, context.team_id], { encoding: 'utf8' }));
652
+ try { service = JSON.parse(execFileSync(serviceBin, ['__teams-connector-service', 'install', contextName, path, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); }
653
+ catch (error) {
654
+ // A platform with no service manager still connects: here, for as long as this terminal runs it. Anything else is an error.
655
+ if (flags.install || !/no service manager/.test(String(error.stderr ?? error.message))) { core.close(); throw error; }
656
+ process.stderr.write('No service manager on this platform, so this machine stays connected while this command runs (Ctrl+C to stop).\n');
657
+ }
658
+ }
659
+ if (service) {
543
660
  saved = state.update(current => ({ ...current, service }));
544
661
  core.close();
545
662
  if (!flags.json) {
@@ -562,7 +679,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
562
679
  const stopAll = async () => { await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
563
680
  try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
564
681
  output({ version: 1, context: scope(context), data: { machine: saved.machine, source: saved.source, roots: saved.roots.map(({ path: _path, ...item }) => item), socket: machine.socketPath, foreground: true } });
565
- await new Promise(resolveStop => { let stopped = false; stopConnector = async () => { if (stopped) return; stopped = true; await stopAll(); resolveStop(); }; process.once('SIGINT', stopConnector); process.once('SIGTERM', stopConnector); });
682
+ await new Promise(resolveStop => { let stopped = false; stopConnector = async () => { if (stopped) return; stopped = true; await stopAll(); resolveStop(); }; for (const signal of ['SIGINT', 'SIGTERM', ...(process.platform === 'win32' ? ['SIGHUP', 'SIGBREAK'] : [])]) process.once(signal, stopConnector); });
566
683
  return;
567
684
  }
568
685
  const enrollment = state.read();
@@ -572,7 +689,8 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
572
689
  if (!serviceBin) unsupported('disconnecting an installed connector requires invocation through the native supercode CLI');
573
690
  execFileSync(serviceBin, ['__teams-connector-service', 'uninstall', contextName, enrollment.roots?.[0]?.path ?? '.', context.server_id, context.team_id], { encoding: 'utf8' });
574
691
  }
575
- if (enrollment.process?.socket && enrollment.process?.token) await requestStop(enrollment.process.socket, enrollment.process.token);
692
+ // Uninstalling a Windows task already stopped its connector, so only one still answering is asked to stop.
693
+ if (enrollment.process?.socket && enrollment.process?.token && await activeSocket(enrollment.process.socket)) await requestStop(enrollment.process.socket, enrollment.process.token);
576
694
  const machine = await team.machines.get(enrollment.machine.id);
577
695
  await client.request('DELETE', teamPath(context, `/machines/${enrollment.machine.id}`), { ifMatch: machine.data.revision });
578
696
  state.remove();
@@ -707,6 +825,7 @@ function printHelp() {
707
825
  process.stdout.write(`supercode workspace commands:
708
826
  context create|list|show|use|remove
709
827
  teams list|create
828
+ auth login|status|switch|logout (one Volter sign-in; "supercode login" is "auth login")
710
829
  login [ORIGIN] [--team TEAM] [--as CONTEXT] [--token-stdin|--invite-stdin] (Volter sign-in in the browser by default)
711
830
  identity link
712
831
  server start [--volter-issuer ORIGIN] [--delegated-issuer ORIGIN]
@@ -96,8 +96,18 @@ export class TeamsConnector {
96
96
  const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
97
97
  if(!locator||!nativeSessionId||!harness)continue;
98
98
  const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:root.path??root.workspace};
99
- await this.capture(options);
99
+ // One session that cannot be captured (a transcript that rewrote its history needs an explicit resync)
100
+ // is recorded, logged and skipped; it must not stop every other session, or the connector itself, from
101
+ // syncing. An error of the connection itself (the outbox, authentication, the server, a stop) still ends
102
+ // the pass. The session keeps its native binding either way, so it can still be attached to.
103
+ let captured = true;
104
+ try { await this.capture(options); } catch (error) {
105
+ if (error.outbox || error.status === 401 || error.status === 403 || error.status >= 500 || this.abort.signal.aborted) throw error;
106
+ captured = false; this.lastError = error;
107
+ process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
108
+ }
100
109
  try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
110
+ if (!captured) continue;
101
111
  const key=`${rule.id}:${harness}:${nativeSessionId}`;
102
112
  if(this.followers.has(key)||typeof this.core.session(locator).follow!=='function')continue;
103
113
  const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
@@ -169,6 +179,8 @@ export class TeamsConnector {
169
179
  this.work=work.catch(()=>{});
170
180
  return work;
171
181
  }
172
- async flushSerial(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);let receipt;if(begun.state==='committed')receipt=begun.receipt;else{for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);for(const artifact of item.artifacts??[])for(const chunk of artifact.chunks)await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);receipt=await this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;}this.state.origins[key]=next;this.state.outbox.shift();this.save();latest=receipt;}return latest;}
182
+ // Every outbox error is the connection's, not one session's: a capture's own catch passes it on (syncConfiguredSessions).
183
+ async flushSerial(){try{return await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}}
184
+ async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);let receipt;if(begun.state==='committed')receipt=begun.receipt;else{for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);for(const artifact of item.artifacts??[])for(const chunk of artifact.chunks)await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);receipt=await this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;}this.state.origins[key]=next;this.state.outbox.shift();this.save();latest=receipt;}return latest;}
173
185
  stop(){if(this.timer)clearInterval(this.timer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
174
186
  }
package/machine.mjs CHANGED
@@ -33,6 +33,9 @@ import { 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';
36
+ // The ConPTY host is read from the namespace, where a terminal package too old to have it gives undefined rather
37
+ // than failing the load: a POSIX daemon never needs it. Windows needs a supercode-terminal with PtyTerminalHost.
38
+ import * as terminal from '@volter/supercode-terminal';
36
39
  import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
37
40
  import { NODE_PROTOCOL } from '@volter/teams/channel';
38
41
  import { holds, observesPanes } from '@volter/teams/admission';
@@ -75,7 +78,9 @@ export class TeamsMachine extends MachineCore {
75
78
  this.supercodeBin = supercode;
76
79
  this.tmuxSocket = tmuxSocket ?? null;
77
80
  this.launches = new Launches(this.p.launches);
78
- this.host = new TmuxTerminalHost({
81
+ // Windows has no tmux: its panes are ConPTY children of this daemon, and end with it.
82
+ if (process.platform === 'win32' && !terminal.PtyTerminalHost) throw new Error('panes on Windows need a newer @volter/supercode-terminal (one with PtyTerminalHost)');
83
+ this.host = process.platform === 'win32' ? new terminal.PtyTerminalHost() : new TmuxTerminalHost({
79
84
  // Stable owner id so panes this daemon launched stay "owned" across restarts.
80
85
  ownerId: `supercode-teams-${fingerprint(this.identity.publicKeyPem)}`,
81
86
  ...(tmuxSocket ? { socket: tmuxSocket } : {}),
@@ -376,7 +381,8 @@ export class TeamsMachine extends MachineCore {
376
381
  #paneEnv(contextKey, requested) {
377
382
  const env = { ...(requested ?? {}) };
378
383
  const inherited = env.NODE_OPTIONS ?? this.env.NODE_OPTIONS ?? '';
379
- env.NODE_OPTIONS = `${inherited} --require ${PORTHOLE_HOOK}`.trim();
384
+ // Quoted: NODE_OPTIONS splits on spaces, and a home path may hold one (Windows user folders often do).
385
+ env.NODE_OPTIONS = `${inherited} --require ${JSON.stringify(PORTHOLE_HOOK)}`.trim();
380
386
  env.SUPERCODE_TEAMS_PORTS_DIR = this.portsDir;
381
387
  env.SUPERCODE_TEAMS_PANE = contextKey;
382
388
  return env;
@@ -630,6 +636,8 @@ export class TeamsMachine extends MachineCore {
630
636
  async #renameWindow(contextKey, title) {
631
637
  const session = await this.#paneByContext(contextKey);
632
638
  if (!session || !session.owned) return null;
639
+ // A host with windows of its own renames them itself (a ConPTY pane has none).
640
+ if (typeof this.host.renameWindow === 'function') return this.host.renameWindow(session.id, title);
633
641
  const socketArgs = this.tmuxSocket ? ['-L', this.tmuxSocket] : [];
634
642
  const target = `${session.label}:0`;
635
643
  const run = (args) => spawnSync('tmux', [...socketArgs, ...args], { timeout: 5_000, encoding: 'utf8' });
@@ -1149,7 +1157,7 @@ export class TeamsMachine extends MachineCore {
1149
1157
  * pane's tty. When that is the pane's own shell, no command is running and the
1150
1158
  * shell is at its prompt; when it is anything else, that thing is the work. The
1151
1159
  * list is names, not paths, because that is the form tmux reports. */
1152
- const PANE_SHELLS = new Set(['ash', 'bash', 'csh', 'dash', 'fish', 'ksh', 'ksh93', 'nu', 'pwsh', 'sh', 'tcsh', 'xonsh', 'zsh']);
1160
+ const PANE_SHELLS = new Set(['ash', 'bash', 'cmd', 'cmd.exe', 'csh', 'dash', 'fish', 'ksh', 'ksh93', 'nu', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe', 'sh', 'tcsh', 'xonsh', 'zsh']);
1153
1161
 
1154
1162
  /** Liveness of a live pane from its foreground command (attention.mjs LIVENESS).
1155
1163
  * A missing or empty value is `unknown`, the honest floor: tmux named nothing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
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",
@@ -62,7 +62,9 @@
62
62
  "@volter/supercode-harness-sdk": "^0.3.43",
63
63
  "@volter/supercode-terminal": "^0.2.30",
64
64
  "@volter/identity": "^0.4.0",
65
- "@volter/teams": "^0.1.6",
65
+ "@volter/teams": "^0.1.7",
66
+ "@xterm/addon-serialize": "^0.14.0",
67
+ "@xterm/headless": "^6.0.0",
66
68
  "protobufjs": "8.8.0",
67
69
  "ws": "^8.21.0"
68
70
  },