@volter/supercode-teams 0.2.20 → 0.2.22

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));
@@ -0,0 +1,48 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+
5
+ const canonical = path => { try { return realpathSync.native(path); } catch { return resolve(path); } };
6
+ const folded = (path, platform) => platform === 'win32' || platform === 'darwin' ? path.toLowerCase() : path;
7
+ const contains = (outer, inner) => { const name = relative(outer, inner); return name === '' || (!isAbsolute(name) && name !== '..' && !name.startsWith('..' + sep)); };
8
+
9
+ /**
10
+ * Why connecting `path` as a Teams sync root would share every session on this machine, or null.
11
+ * A rule covers its root and every folder under it, so a filesystem or drive root, the user's home, or
12
+ * a folder holding the home (`/Users`, `C:\Users`) publishes everything the harnesses ever recorded.
13
+ */
14
+ export function broadRootReason(path, { home = homedir(), platform = process.platform } = {}) {
15
+ const root = canonical(path);
16
+ if (dirname(root) === root) return 'a filesystem root';
17
+ if (!home) return null;
18
+ const user = canonical(home);
19
+ if (folded(root, platform) === folded(user, platform)) return 'your home directory';
20
+ if (contains(folded(root, platform), folded(user, platform))) return 'a folder that holds your home directory';
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * How `teams connect` registers `path`: the fields its saved root carries.
26
+ * - An ordinary folder: `{}` (the rule covers the folder and every folder under it).
27
+ * - A broad root with --force, or one accepted with --force before: `{ broad_accepted: true }`.
28
+ * - A broad root this enrollment already had before rules covered subfolders (saved without
29
+ * `broad_accepted`), rerun without a terminal by the background service: `{ exact_only: true }`. It keeps
30
+ * syncing only sessions recorded in that exact folder, as it did, and says so on stderr; refusing it
31
+ * would silently stop the machine's sync.
32
+ * - Otherwise a broad root is refused.
33
+ */
34
+ export function connectRootScope(path, { force = false, saved = null, interactive = false, warn = message => process.stderr.write(message), ...options } = {}) {
35
+ const previous = saved?.roots?.find(item => item.path === path) ?? null;
36
+ const reason = refuseBroadRoot(path, { force, accepted: Boolean(previous?.broad_accepted) || (Boolean(previous) && !interactive), ...options });
37
+ if (!reason) return {};
38
+ if (force || previous?.broad_accepted) return { broad_accepted: true };
39
+ warn(`supercode teams: ${path} is ${reason}. Its sync rules keep sharing only sessions recorded in that exact folder; sessions in its subfolders are not shared. To share every session under it, run \`supercode teams connect --force\` from that folder.\n`);
40
+ return { exact_only: true };
41
+ }
42
+
43
+ /** Refuse a broad root unless the caller passed --force (or accepted this same root before). */
44
+ export function refuseBroadRoot(path, { force = false, accepted = false, verb = 'connect', ...options } = {}) {
45
+ const reason = broadRootReason(path, options);
46
+ if (!reason || force || accepted) return reason;
47
+ throw Object.assign(new Error(`refusing to ${verb} ${path}: it is ${reason}, and a Teams sync rule covers every session whose working directory is that folder or any folder under it, so it would share every session on this machine. Choose a project folder, or pass --force to share them all.`), { code: 'invalid_request', exitCode: 2 });
48
+ }
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,9 @@ 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';
24
+ import { connectRootScope, refuseBroadRoot } from './workspace/roots.mjs';
22
25
 
23
26
  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
27
 
@@ -35,6 +38,7 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
35
38
  if (verb === 'context') return contextCommand({ contexts, flags, sub, rest, output });
36
39
  if (verb === 'server') return serverCommand({ flags, sub, rest, home, output });
37
40
  if (verb === 'login') return loginCommand({ flags, origin: sub, rest, home, output });
41
+ if (verb === 'auth') return authCommand({ flags, sub, rest, home, contexts, output, env, dependencies });
38
42
  const explicitContext = flags.context ?? env.SUPERCODE_CONTEXT;
39
43
  let selected = explicitContext ?? contexts.read().current;
40
44
  const urlReference = verb === 'sessions' ? rest.find(value => /^https?:\/\//.test(value)) : null;
@@ -145,6 +149,112 @@ async function loginCommand({ flags, origin, rest, home, output }) {
145
149
  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
150
  }
147
151
 
152
+ /**
153
+ * `supercode auth`: one Volter sign-in for everything supercode does, the way `gh auth` signs in to GitHub.
154
+ * login [ORIGIN] sign in (or say who is), make that team current, and offer to connect this machine
155
+ * status who is signed in, which team, and whether this machine is connected
156
+ * switch [NAME] make another saved team current (without NAME, list them)
157
+ * logout forget the sign-in and every team it signed into (refused while this machine is connected)
158
+ * Everything a person reads goes to stdout: none of it is an error.
159
+ */
160
+ async function authCommand({ flags, sub = 'status', rest, home, contexts, output, env }) {
161
+ // A person reads stdout; with --json it carries only the JSON, and progress goes to stderr.
162
+ const say = line => (flags.json ? process.stderr : process.stdout).write(`${line}\n`);
163
+ if (sub === 'login') {
164
+ only(flags, ['json', 'help', 'team', 'as', 'force', 'no_connect', 'cwd', 'volter_issuer']);
165
+ requireArgs(rest, 0, 1, 'auth login [ORIGIN] [--team TEAM] [--as NAME] [--force] [--no-connect] [--cwd DIR]');
166
+ const origin = rest[0] ?? env.SUPERCODE_TEAMS_ORIGIN ?? DEFAULT_TEAMS_ORIGIN;
167
+ let account = authAccount(home, contexts);
168
+ if (account.context && !flags.force && !flags.team && !rest[0]) {
169
+ if (flags.json) return output(account);
170
+ say(`✓ Already logged in to Volter as ${account.email ?? 'you'} · team ${account.team}`);
171
+ } else {
172
+ const signIn = { ...(flags.volter_issuer ? { issuer: flags.volter_issuer } : {}), audience: new URL(origin).origin, say };
173
+ // As `teams login`: --team signs into that team; otherwise the person chooses among the teams they may use
174
+ // (volter-login.mjs), and the context it leaves current is the one this login reports.
175
+ if (flags.team) {
176
+ 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 });
177
+ const name = Object.entries(contexts.read().contexts).find(([, value]) => value.credential_ref === saved.credential_ref)?.[0];
178
+ if (name) contexts.use(name);
179
+ } else {
180
+ const signedInTo = await volterDeviceSignIn({ ...signIn, finish: ({ token, ask }) => signInToTeams({ supercodeHome: home, origin, identityToken: token, ask, as: flags.as ?? null }) });
181
+ for (const { team, reason } of signedInTo.failed ?? []) say(`Not signed in to ${team}: ${reason}`);
182
+ }
183
+ account = authAccount(home, contexts);
184
+ if (flags.json) return output(account);
185
+ say(`✓ Logged in to Volter as ${account.email ?? 'you'} · team ${account.team ?? 'none'}${account.others.length ? ` (also: ${account.others.join(', ')}; supercode auth switch)` : ''}`);
186
+ }
187
+ if (flags.no_connect || !account.context || !process.stdin.isTTY || !process.stdout.isTTY) return;
188
+ const command = authTeamCommand(home, account.context, flags, output, env);
189
+ if (command.state().read()?.machine) return;
190
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
191
+ let folder;
192
+ try {
193
+ 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();
194
+ if (/^n/i.test(yes)) { say(`Not connected. Later: supercode teams connect`); return; }
195
+ folder = (await readline.question(`Folder to share [${flags.cwd ?? process.cwd()}]: `)).trim() || (flags.cwd ?? process.cwd());
196
+ } finally { readline.close(); }
197
+ // As `teams connect` from a terminal: a service where the platform has a manager, else connected while it runs
198
+ // (the prompt is closed first, so Ctrl+C reaches the connector rather than the prompt).
199
+ command.flags = { cwd: folder };
200
+ return await connectorCommand('connect', command, home);
201
+ }
202
+ if (sub === 'status') {
203
+ only(flags, ['json', 'help']); requireArgs(rest, 0, 0, 'auth status');
204
+ const account = authAccount(home, contexts);
205
+ if (account.context) {
206
+ const command = authTeamCommand(home, account.context, flags, output, env);
207
+ const enrollment = command.state().read();
208
+ 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;
209
+ try { await command.client.me(); account.reachable = true; } catch { account.reachable = false; }
210
+ if (account.machine && account.reachable) account.machine.connection = (await command.team.machines.get(account.machine.id).catch(() => null))?.data?.connection ?? null;
211
+ }
212
+ if (flags.json) return output(account);
213
+ say(account.email ? `Volter ✓ ${account.email}` : 'Volter not logged in (supercode login)');
214
+ if (!account.context) return say(account.others.length ? `Team none current · saved: ${account.others.join(', ')} (supercode auth switch NAME)` : 'Team none (supercode login)');
215
+ say(`Team ${account.team} · ${new URL(account.origin).host}${account.reachable ? '' : ' · unreachable'}${account.others.length ? ` · also: ${account.others.join(', ')} (supercode auth switch)` : ''}`);
216
+ 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)');
217
+ return;
218
+ }
219
+ if (sub === 'switch') {
220
+ only(flags, ['json', 'help']); requireArgs(rest, 0, 1, 'auth switch [NAME]');
221
+ const doc = contexts.read(), names = Object.keys(doc.contexts);
222
+ 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)');
223
+ contexts.use(rest[0]);
224
+ return say(`✓ Switched to ${doc.contexts[rest[0]]?.team_name ?? rest[0]}`);
225
+ }
226
+ if (sub === 'logout') {
227
+ only(flags, ['json', 'help']); requireArgs(rest, 0, 0, 'auth logout');
228
+ const account = authAccount(home, contexts), doc = contexts.read();
229
+ // One sign-in is one credential, shared by every team context it signed into, so logging out ends all of them.
230
+ const credentialRef = account.context ? doc.contexts[account.context]?.credential_ref : null;
231
+ const signedIn = Object.entries(doc.contexts).filter(([name, context]) => name === account.context || (credentialRef && context.credential_ref === credentialRef));
232
+ // A connected machine's connector runs as one of those contexts, so logging out would strand it: disconnect first.
233
+ const connected = signedIn.find(([, context]) => contexts.connector(context.server_id, context.team_id).read()?.machine);
234
+ 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 });
235
+ const signedOut = signOut();
236
+ for (const [name] of signedIn) contexts.remove(name);
237
+ if (credentialRef) rmSync(join(contexts.paths.credentials, `${credentialRef}.json`), { force: true });
238
+ const teams = signedIn.map(([name, context]) => context.team_name ?? name);
239
+ if (flags.json) return output({ signed_out: signedOut, removed_contexts: signedIn.map(([name]) => name) });
240
+ 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');
241
+ }
242
+ usage('auth login|status|switch|logout');
243
+ }
244
+
245
+ /** Who is signed in here: the machine's shared Volter sign-in and the current team context. */
246
+ function authAccount(home, contexts) {
247
+ const doc = contexts.read(), name = doc.current && doc.current !== 'local' ? doc.current : null, context = name ? doc.contexts[name] : null;
248
+ const shared = readSharedSignIn();
249
+ 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) };
250
+ }
251
+
252
+ /** The command a team verb runs with, for the current team context. */
253
+ function authTeamCommand(home, contextName, flags, output, env) {
254
+ const { client, context } = fromContext({ supercodeHome: home, name: contextName, fetch: globalThis.fetch });
255
+ 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) };
256
+ }
257
+
148
258
  /** What a login says: the context as JSON, or to a person, the team they are in and the next step. */
149
259
  function signedIn(flags, output, saved) {
150
260
  if (flags.json) return output(saved);
@@ -498,6 +608,7 @@ async function syncCommand(sub, { flags, rest, output, client, context, team, ho
498
608
  if (!enrollment?.machine?.id) unsupported('this context has no connector enrollment');
499
609
  const path = realpathSync(resolve(flags.repo)), root = enrollment.roots?.find(item => item.path === path);
500
610
  if (!root) unsupported('the connector has not registered that repository root; restart connect with --cwd PATH');
611
+ refuseBroadRoot(path, { force: flags.force, verb: 'sync' });
501
612
  if (flags.harness && !root.harnesses.includes(flags.harness)) unsupported(`the native core does not report a ${flags.harness} discovery adapter`);
502
613
  return output(await team.syncRules.create({ machine_id: enrollment.machine.id, source_id: enrollment.source.id, selector: { root_id: root.id, harnesses: flags.harness ? [flags.harness] : root.harnesses }, visibility: flags.visibility ?? 'restricted', backfill: backfill(flags.backfill ?? 'none') }));
503
614
  }
@@ -517,6 +628,11 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
517
628
  if (verb === 'status') { let reachable = true; let account = null; try { account = await client.me(); } catch { reachable = false; } const saved = state.read(); const running = saved?.process?.socket ? await activeSocket(saved.process.socket) : false; return output({ version: 1, context: scope(context), data: { reachable, account, connector: saved ? { enrolled: true, running, machine: saved.machine, source: saved.source } : { enrolled: false, running: false }, sources: reachable ? (await team.sources.list()).items : [] } }); }
518
629
  if (verb === 'connect') {
519
630
  let saved = state.read();
631
+ const path = realpathSync(resolve(flags.cwd ?? '.'));
632
+ // A broad root is refused before enrolling anything. The service reruns connect without a terminal, and
633
+ // without --force, for the root accepted here; a broad root enrolled before rules covered subfolders keeps
634
+ // its exact-folder sync there.
635
+ const rootScope = connectRootScope(path, { force: flags.force, saved, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
520
636
  if (!saved) { const enrollment_key = crypto.randomUUID(); saved = { version: 1, enrollment_key, phase: 'enrolling' }; state.write(saved); }
521
637
  if (!saved.machine) {
522
638
  const response = await team.machines.create({ name: flags.name ?? hostname(), enrollment_key: saved.enrollment_key });
@@ -531,15 +647,22 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
531
647
  try { support = await core.supportReport(); } catch (error) { core.close(); throw error; }
532
648
  const harnesses = [...new Set((support.harnesses ?? []).map(item => item.id).filter(id => typeof id === 'string'))];
533
649
  if (!harnesses.length) { core.close(); unsupported('the native core reported no discoverable harness adapters'); }
534
- const path = realpathSync(resolve(flags.cwd ?? '.'));
535
- const root = { id: `root_${createHash('sha256').update(`${saved.machine.id}\0${path}`).digest('hex').slice(0, 32)}`, machine_id: saved.machine.id, name: basename(path), harnesses, path };
650
+ const root = { id: `root_${createHash('sha256').update(`${saved.machine.id}\0${path}`).digest('hex').slice(0, 32)}`, machine_id: saved.machine.id, name: basename(path), harnesses, path, ...rootScope };
536
651
  saved = state.update(current => ({ ...current, roots: [root] }));
537
652
  // Run from a terminal, connect installs the service that keeps this machine connected (launchd or systemd) and
538
653
  // returns; the service itself runs connect without a terminal, in the foreground. --foreground runs it here.
654
+ let service = null;
539
655
  if (flags.install || (!flags.foreground && process.stdin.isTTY && process.stdout.isTTY)) {
540
656
  const serviceBin = process.env.SUPERCODE_BIN;
541
657
  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' }));
658
+ try { service = JSON.parse(execFileSync(serviceBin, ['__teams-connector-service', 'install', contextName, path, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); }
659
+ catch (error) {
660
+ // A platform with no service manager still connects: here, for as long as this terminal runs it. Anything else is an error.
661
+ if (flags.install || !/no service manager/.test(String(error.stderr ?? error.message))) { core.close(); throw error; }
662
+ process.stderr.write('No service manager on this platform, so this machine stays connected while this command runs (Ctrl+C to stop).\n');
663
+ }
664
+ }
665
+ if (service) {
543
666
  saved = state.update(current => ({ ...current, service }));
544
667
  core.close();
545
668
  if (!flags.json) {
@@ -559,10 +682,11 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
559
682
  const hostKeyId = publicKeyId(loadOrCreateKeyPair(paths(daemonHome).machineKey).publicKeyPem);
560
683
  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 });
561
684
  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() } });
685
+ 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`); });
562
686
  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
687
  try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
564
688
  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); });
689
+ 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
690
  return;
567
691
  }
568
692
  const enrollment = state.read();
@@ -572,7 +696,8 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
572
696
  if (!serviceBin) unsupported('disconnecting an installed connector requires invocation through the native supercode CLI');
573
697
  execFileSync(serviceBin, ['__teams-connector-service', 'uninstall', contextName, enrollment.roots?.[0]?.path ?? '.', context.server_id, context.team_id], { encoding: 'utf8' });
574
698
  }
575
- if (enrollment.process?.socket && enrollment.process?.token) await requestStop(enrollment.process.socket, enrollment.process.token);
699
+ // Uninstalling a Windows task already stopped its connector, so only one still answering is asked to stop.
700
+ if (enrollment.process?.socket && enrollment.process?.token && await activeSocket(enrollment.process.socket)) await requestStop(enrollment.process.socket, enrollment.process.token);
576
701
  const machine = await team.machines.get(enrollment.machine.id);
577
702
  await client.request('DELETE', teamPath(context, `/machines/${enrollment.machine.id}`), { ifMatch: machine.data.revision });
578
703
  state.remove();
@@ -707,6 +832,7 @@ function printHelp() {
707
832
  process.stdout.write(`supercode workspace commands:
708
833
  context create|list|show|use|remove
709
834
  teams list|create
835
+ auth login|status|switch|logout (one Volter sign-in; "supercode login" is "auth login")
710
836
  login [ORIGIN] [--team TEAM] [--as CONTEXT] [--token-stdin|--invite-stdin] (Volter sign-in in the browser by default)
711
837
  identity link
712
838
  server start [--volter-issuer ORIGIN] [--delegated-issuer ORIGIN]
@@ -25,8 +25,20 @@ function atomic(path, value) {
25
25
  }
26
26
  export class TeamsConnector {
27
27
  constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
28
- save(){atomic(this.statePath,this.state);} async request(method,path,body){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);const response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:this.abort.signal});if(response.type==='opaqueredirect'||response.status>=300&&response.status<400)throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400});const value=await response.json();if(!response.ok)throw Object.assign(new Error(value.error?.message??`HTTP ${response.status}`),value.error,{status:response.status});return value.data;}
29
- async configuration(){const value=await this.request('GET',`/machines/${this.machineId}/configuration`);this.configurationValue=value;return value;}
28
+ save(){atomic(this.statePath,this.state);} async request(method,path,body){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);let response;for(let attempt=0;;attempt++){response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:this.abort.signal});
29
+ // A rate limit is waited out (the server says how long; the same idempotency key makes the retry safe), so a
30
+ // large first sync slows down instead of failing its pass and restarting the connector.
31
+ if(response.status!==429||attempt>=5)break;// The server names the wait in error.details (wire.mjs validationFailure); a Retry-After header is the fallback.
32
+ const limited=await response.json().catch(()=>null),named=limited?.error?.details?.retry_after_seconds??limited?.error?.retry_after_seconds??response.headers?.get?.('retry-after'),asked=named==null||named===''?NaN:Number(named),seconds=Number.isFinite(asked)?Math.min(60,Math.max(0,asked)):1;
33
+ await new Promise((resolve,reject)=>{const stop=()=>{clearTimeout(timer);reject(this.abort.signal.reason);},timer=setTimeout(()=>{this.abort.signal.removeEventListener('abort',stop);resolve();},seconds*1000);this.abort.signal.addEventListener('abort',stop,{once:true});});}if(response.type==='opaqueredirect'||response.status>=300&&response.status<400)throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400});const value=await response.json();if(!response.ok)throw Object.assign(new Error(value.error?.message??`HTTP ${response.status}`),value.error,{status:response.status});return value.data;}
34
+ async configuration(){const value=await this.request('GET',`/machines/${this.machineId}/configuration`);this.configurationValue=value;this.pruneStamps(value);return value;}
35
+ // A capture stamp belongs to one rule; once that rule is no longer active its stamps are dropped.
36
+ pruneStamps(configuration){
37
+ if(!Array.isArray(configuration?.rules))return;
38
+ const active=new Set(configuration.rules.filter(rule=>rule.state==='active').map(rule=>rule.id));let pruned=false;
39
+ for(const origin of Object.values(this.state.origins??{}))for(const ruleId of Object.keys(origin?.capture_stamps??{}))if(!active.has(ruleId)){delete origin.capture_stamps[ruleId];pruned=true;}
40
+ if(pruned)this.save();
41
+ }
30
42
  async start({bindings=()=>[],roots=()=>[]}={}) {
31
43
  await this.configuration();
32
44
  const heartbeat = async () => {
@@ -82,61 +94,123 @@ export class TeamsConnector {
82
94
  this.nativeEffects.beginDiscovery();
83
95
  const activeRules=new Map(configuration.rules.filter(item=>item.state==='active').map(rule=>[rule.id,rule]));
84
96
  for(const [key,follower] of this.followers){const rule=activeRules.get(follower.ruleId);if(!rule||rule.revision!==follower.ruleRevision){follower.controller.abort();this.followers.delete(key);}}
97
+ // A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
98
+ // reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
99
+ // machine; rules over the same root and harnesses share the scan.
100
+ const discoveries=new Map();
85
101
  for(const rule of activeRules.values()){
86
102
  const root=roots.find(item=>item.id===rule.selector.root_id);
87
103
  if(!root)continue;
88
- const descriptors=[];
89
- let cursor;
90
- do {
91
- const page=await this.core.discover({workspace:root.path??root.workspace,harnesses:rule.selector.harnesses,homes:root.homes,limit:2048,...(cursor?{cursor}:{})});
92
- descriptors.push(...(page.sessions??page.entries??[]));
93
- cursor=page.next_cursor??page.nextCursor??null;
94
- } while(cursor);
104
+ const workspace=root.path??root.workspace,subtree=!root.exact_only,discoveryKey=JSON.stringify([workspace,subtree,[...(rule.selector.harnesses??[])].sort(),root.homes??null]);
105
+ if(!discoveries.has(discoveryKey))discoveries.set(discoveryKey,(async()=>{
106
+ const descriptors=[];
107
+ let cursor;
108
+ do {
109
+ // A broad root enrolled before rules covered subfolders keeps its exact-folder discovery.
110
+ const page=await this.core.discover({workspace,...(subtree?{workspace_subtree:true}:{}),harnesses:rule.selector.harnesses,homes:root.homes,limit:2048,...(cursor?{cursor}:{})});
111
+ descriptors.push(...(page.sessions??page.entries??[]));
112
+ cursor=page.next_cursor??page.nextCursor??null;
113
+ } while(cursor);
114
+ return descriptors;
115
+ })());
116
+ const descriptors=await discoveries.get(discoveryKey);
95
117
  for(const descriptor of descriptors){
96
118
  const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
97
119
  if(!locator||!nativeSessionId||!harness)continue;
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);
120
+ const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:workspace,configuration};
121
+ // A session whose native store has not changed since this rule last captured it is not reloaded.
122
+ // One session that cannot be captured (a transcript that rewrote its history needs an explicit resync)
123
+ // is recorded, logged and skipped; it must not stop every other session, or the connector itself, from
124
+ // syncing. An error of the connection itself (the outbox, authentication, the server, a stop) still ends
125
+ // the pass. The session keeps its native binding either way, so it can still be attached to.
126
+ let captured = true;
127
+ if (this.sessionChanged(options)) try { await this.capture(options); } catch (error) {
128
+ if (error.outbox || error.status === 401 || error.status === 403 || error.status >= 500 || this.abort.signal.aborted) throw error;
129
+ captured = false; this.lastError = error;
130
+ process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
131
+ }
100
132
  try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
133
+ if (!captured) continue;
101
134
  const key=`${rule.id}:${harness}:${nativeSessionId}`;
102
135
  if(this.followers.has(key)||typeof this.core.session(locator).follow!=='function')continue;
103
136
  const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
104
- const task=(async()=>{for await(const _event of this.core.session(locator).follow({signal:controller.signal})){if(controller.signal.aborted)break;await this.capture(options);}})().catch(error=>{if(!controller.signal.aborted)this.lastError=error;}).finally(()=>{this.abort.signal.removeEventListener('abort',stop);this.followers.delete(key);});
137
+ // A follow event may land between beats, after the rule changed: it reads the configuration afresh.
138
+ const followed={...options,configuration:null,stamp:undefined};
139
+ const task=(async()=>{for await(const _event of this.core.session(locator).follow({signal:controller.signal})){if(controller.signal.aborted)break;await this.capture(followed);}})().catch(error=>{if(!controller.signal.aborted)this.lastError=error;}).finally(()=>{this.abort.signal.removeEventListener('abort',stop);this.followers.delete(key);});
105
140
  this.followers.set(key,{ruleId:rule.id,ruleRevision:rule.revision,controller,task});
106
141
  }
107
142
  }
108
143
  this.nativeEffects.endDiscovery();
109
144
  }
145
+ // What a session's native store looked like when discovery listed it: its file's size and modification
146
+ // time (read before any load, so a write after it reads as a change), those of the file's SQLite write-ahead
147
+ // log, and the descriptor's updated_at_ms and message_count. A SQLite store (Hermes state.db, OpenCode,
148
+ // Goose) is shared by many sessions, and a live one appends to `<path>-wal` until a checkpoint while the
149
+ // database file, and a Hermes session's updated_at_ms (its start until it ends), stay put; its message count
150
+ // still moves. Null when nothing is known; such a session is reloaded every beat.
151
+ sessionStamp({locator,descriptor}){
152
+ const stat=path=>{try{const value=statSync(path);return `${value.size}:${value.mtimeMs}`;}catch{return null;}};
153
+ const path=typeof locator?.storage?.path==='string'?locator.storage.path:null;
154
+ const file=path?stat(path):null,wal=path?stat(`${path}-wal`):null;
155
+ const updated=Number.isSafeInteger(descriptor?.updated_at_ms)?descriptor.updated_at_ms:null;
156
+ const count=Number.isSafeInteger(descriptor?.message_count)?descriptor.message_count:null;
157
+ return file===null&&updated===null&&count===null?null:JSON.stringify([file,wal,updated,count]);
158
+ }
159
+ sessionChanged(options){
160
+ const stamp=this.sessionStamp(options);options.stamp=stamp;
161
+ if(stamp===null||this.state.outbox.length)return true;
162
+ const rule=options.configuration?.rules?.find(item=>item.id===options.ruleId&&item.revision===options.ruleRevision&&item.state==='active');if(!rule)return true;
163
+ const configured=rule.backfill??options.backfill??'all',mode=typeof configured==='string'?configured:configured.mode;
164
+ const seen=this.state.origins[`${options.harness}:${options.nativeSessionId}`]?.capture_stamps?.[options.ruleId];
165
+ return !(seen&&seen.stamp===stamp&&seen.rule_revision===options.ruleRevision&&seen.backfill_mode===mode);
166
+ }
167
+ recordStamp(key,ruleId,ruleRevision,backfillMode,stamp){
168
+ const origin=this.state.origins[key];
169
+ if(origin&&ruleId&&stamp)origin.capture_stamps={...origin.capture_stamps,[ruleId]:{stamp,rule_revision:ruleRevision,backfill_mode:backfillMode}};
170
+ this.save();
171
+ }
110
172
  capture(options) {
111
173
  const work=this.work.then(()=>this.captureSerial(options));
112
174
  this.work=work.catch(()=>{});
113
175
  return work;
114
176
  }
115
- async captureSerial({locator,descriptor=null,harness,nativeSessionId,visibility='restricted',ruleId=null,ruleRevision=null,coverage={completeness:'complete',reasons:[],missing:[]},backfill='all',rootPath=null}) {
177
+ async captureSerial({locator,descriptor=null,harness,nativeSessionId,visibility='restricted',ruleId=null,ruleRevision=null,coverage={completeness:'complete',reasons:[],missing:[]},backfill='all',rootPath=null,configuration=null,stamp}) {
116
178
  if(this.state.outbox.length)await this.flushSerial();
117
- const configuration=await this.configuration(),rule=configuration.rules.find(item=>item.id===ruleId&&item.revision===ruleRevision&&item.state==='active');if(!rule)throw Object.assign(new Error('sync rule is absent, inactive, or stale'),{code:'forbidden'});visibility=rule.visibility;
179
+ // Discovery hands each session the configuration its beat fetched; a follow event fetches its own.
180
+ if(stamp===undefined)stamp=this.sessionStamp({locator,descriptor});
181
+ if(!configuration)configuration=await this.configuration();const rule=configuration.rules.find(item=>item.id===ruleId&&item.revision===ruleRevision&&item.state==='active');if(!rule)throw Object.assign(new Error('sync rule is absent, inactive, or stale'),{code:'forbidden'});visibility=rule.visibility;
118
182
  const artifactSource=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
119
183
  const artifactBefore=artifactSource?statSync(artifactSource):null;
120
184
  const loaded=await this.core.session(locator).load(),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
121
185
  const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
122
186
  let messages=(loaded.messages??loaded.turns??[]).map((entry,index)=>{const value=entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key)));return{native_key:entry.id??entry.native_key??String(index),recorded_at:recordedAt(entry),value};});
123
- if(previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index]))return null;
124
- let effectivePrevious=previous;
125
- if(!previous&&backfillPolicy.mode==='none'){this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>hash(canonicalJson(x.value))),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>hash(canonicalJson(x.value))),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.save();return null;}
187
+ if(previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index])){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
188
+ let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
189
+ if(!previous&&backfillPolicy.mode==='none'){this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>hash(canonicalJson(x.value))),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>hash(canonicalJson(x.value))),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
126
190
  if(!previous&&backfillPolicy.mode==='since'){
127
191
  if(!Number.isSafeInteger(backfillPolicy.since))throw Object.assign(new Error('since backfill requires an integer timestamp'),{code:'invalid_request'});
128
192
  const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
129
193
  effectivePrevious={generation:1,sequence:0,digest:null,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
130
194
  this.state.origins[key]=effectivePrevious;
131
195
  }
132
- if(previous?.observed_keys?.every(x=>/^\d+$/.test(x))){
133
- const prefix=messages.slice(0,previous.observed_keys.length);
134
- const rewritten=prefix.length!==previous.observed_keys.length||prefix.some((message,index)=>message.native_key!==previous.observed_keys[index]||hash(canonicalJson(message.value))!==previous.observed_digests?.[index]);
135
- if(rewritten)throw Object.assign(new Error('index-key source rewrote its baseline; explicit backfill is required'),{code:'resync_required'});
196
+ // A source keyed by position (a Claude Code transcript is) can change a message it already reported: the core
197
+ // reports a tool call still running as an interrupted placeholder, and the result replaces it at the same
198
+ // position; a compaction or a parser change rewrites more. Every capture is a whole snapshot (the server keeps
199
+ // only the latest capture's messages as the session's history), so a rewrite of what was published is simply
200
+ // republished by the next capture. Only the unpublished baseline of a `none` or `since` rule is a problem:
201
+ // positions no longer identify the history the rule kept back. A `since` rule derives it again from the
202
+ // timestamps; a `none` rule cannot tell old history from new, so that session waits for an explicit
203
+ // `supercode teams sync resume RULE --backfill all` (or a since window), which republishes it whole.
204
+ if(!clearBaseline&&effectivePrevious?.baseline?.length&&effectivePrevious.baseline.every(x=>/^\d+$/.test(x))){
205
+ const baselineMoved=effectivePrevious.baseline.some((native_key,index)=>messages[index]?.native_key!==native_key||hash(canonicalJson(messages[index].value))!==effectivePrevious.baseline_digests?.[index]);
206
+ if(baselineMoved&&backfillPolicy.mode==='since'&&Number.isSafeInteger(backfillPolicy.since)){
207
+ const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
208
+ effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
209
+ this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};
210
+ }else if(baselineMoved)throw Object.assign(new Error(`the history kept back when rule ${ruleId} started was rewritten; run \`supercode teams sync resume ${ruleId} --backfill all\` (or a since window) to republish this session`),{code:'resync_required'});
136
211
  }
137
- const clearBaseline=backfillPolicy.mode==='all';
138
212
  if(effectivePrevious?.baseline&&!clearBaseline)messages=messages.filter(x=>!effectivePrevious.baseline.includes(x.native_key));
139
- if(!messages.length)return null;
213
+ if(!messages.length){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
140
214
  const captureId=`cap_${randomBytes(16).toString('hex')}`,generation=effectivePrevious?.generation??1,sequence=(effectivePrevious?.sequence??0)+1;
141
215
  const begin={native_session_id:nativeSessionId,harness,generation,sequence,capture_id:captureId,previous_digest:effectivePrevious?.digest??null,visibility,rule_id:ruleId,rule_revision:ruleRevision};
142
216
  const parts=[];for(let index=0;index<messages.length;index+=500)parts.push({capture_id:captureId,index:parts.length,messages:messages.slice(index,index+500)});
@@ -162,13 +236,27 @@ export class TeamsConnector {
162
236
  }
163
237
  const recordedTimes=messages.map(message=>message.recorded_at).filter(Number.isSafeInteger),commit={capture_id:captureId,parts:parts.length,digest:'',title:descriptor?.title??null,repository:null,reported_user:null,recorded_activity_at:recordedTimes.length?Math.max(...recordedTimes):null,parent_native_id:descriptor?.parent_session_id??null,coverage:effectivePrevious?.baseline&&!clearBaseline?{...coverage,completeness:'partial',reasons:[...new Set([...coverage.reasons,'prefix_not_captured'])]}:coverage,artifacts:artifacts.map(item=>item.manifest),...(nativeBundle?{native_bundle:nativeBundle}:{})};
164
238
  const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
165
- this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
239
+ this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
166
240
  }
167
241
  flush() {
168
242
  const work=this.work.then(()=>this.flushSerial());
169
243
  this.work=work.catch(()=>{});
170
244
  return work;
171
245
  }
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;}
246
+ // Every outbox error is the connection's, not one session's: a capture's own catch passes it on (syncConfiguredSessions).
247
+ async flushSerial(){try{return await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}}
248
+ async sendCapture(item){const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;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);return this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}
249
+ // True when the server refused a queued capture because its sync rule was removed, paused or revised since it
250
+ // was queued: the capture can never be accepted, so it must not hold every later capture behind it. The
251
+ // refusal is confirmed against a fresh configuration; any other refusal (a revoked credential, a paused
252
+ // source) or a network or server error keeps the capture queued for the next flush.
253
+ async ruleRejected(item,error){
254
+ if(!item.begin.rule_id||!(error.status===403||error.status===404||error.code==='source_paused'))return false;
255
+ let configuration;try{configuration=await this.configuration();}catch{return false;}
256
+ // A configuration without a rule list says nothing about the rule, so the capture stays queued.
257
+ if(!Array.isArray(configuration?.rules))return false;
258
+ return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
259
+ }
260
+ async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;try{receipt=await this.sendCapture(item);}catch(error){if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}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;}if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();latest=receipt;}return latest;}
173
261
  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
262
  }
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.22",
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.8",
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
  },