@volter/supercode-teams 0.2.177 → 0.2.179

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/teams.mjs CHANGED
@@ -307,12 +307,13 @@ async function main() {
307
307
  case 'workspace': {
308
308
  if (sub === 'list') return withNode(async (rpc) => out(await rpc.call('workspace.list', {})));
309
309
  if (sub === 'register' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.register', { path: rest[0], repository: flags.repo ?? null })));
310
+ if (sub === 'unregister' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.unregister', { path: rest[0] })));
310
311
  if (sub === 'resolve' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.resolve', { repository: rest[0], branch: flags.branch ?? null, solo: Boolean(flags.solo), card: flags.card ?? null, create: Boolean(flags.create) })));
311
312
  if (sub === 'inspect' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.inspect', { path: rest[0] })));
312
313
  // `workspace move --from A --to B --path P [--to-path Q]`: the project leaves A and arrives
313
314
  // at B, published there only if every byte arrived (sha256 on commit) and neither machine
314
315
  // runs an exec job. A is left as it was; B's path must not exist.
315
- if (sub !== 'move' || !flags.from || !flags.to || !flags.path) die('workspace move --from MACHINE --to MACHINE --path P [--to-path Q]');
316
+ if (sub !== 'move' || !flags.from || !flags.to || !flags.path) die('workspace list | register PATH [--repo R] | unregister PATH | resolve REPO | inspect PATH | move --from MACHINE --to MACHINE --path P [--to-path Q]');
316
317
  try {
317
318
  out(await moveWorkspace({ from: String(flags.from), to: String(flags.to), path: String(flags.path), toPath: String(flags['to-path'] ?? flags.path), context: flags.context ? String(flags.context) : null, home }));
318
319
  } catch (error) { die(error.message, error.exitCode ?? 1); }
package/machine.mjs CHANGED
@@ -1080,6 +1080,16 @@ export class TeamsMachine extends MachineCore {
1080
1080
 
1081
1081
  listPorts() { return listAnnouncedPorts(this.portsDir); }
1082
1082
 
1083
+ /** The attach clients on each pane's tmux session, by its label; null where panes are not tmux's (a ConPTY host). */
1084
+ #viewerCounts() {
1085
+ if (process.platform === 'win32') return null;
1086
+ const listed = spawnSync('tmux', [...(this.tmuxSocket ? ['-L', this.tmuxSocket] : []), 'list-clients', '-F', '#{session_name}'], { timeout: 3_000, encoding: 'utf8' });
1087
+ const counts = new Map();
1088
+ if (listed.status !== 0) return counts; // no server, or no clients: nothing shows any pane
1089
+ for (const name of listed.stdout.split('\n').filter(Boolean)) counts.set(name, (counts.get(name) ?? 0) + 1);
1090
+ return counts;
1091
+ }
1092
+
1083
1093
  /**
1084
1094
  * A terminal window on this machine's screen showing the pane, so the people at the machine see what runs on it:
1085
1095
  * in whichever capable terminal is installed (popup-terminals.mjs), running an attach client. The pane ending ends
@@ -1087,7 +1097,7 @@ export class TeamsMachine extends MachineCore {
1087
1097
  */
1088
1098
  #popupWindow(contextKey, session) {
1089
1099
  const terminal = popupTerminal(this.env);
1090
- if (!terminal) return;
1100
+ if (!terminal) return false;
1091
1101
  const attach = process.platform === 'win32'
1092
1102
  ? [process.execPath, fileURLToPath(new URL('./bin/fleet.mjs', import.meta.url)), 'attach', contextKey, '--mode', 'control']
1093
1103
  // Presented as `supercode open`'s own client is (crates/cli/src/open.rs `attach`): the terminal sets its own
@@ -1095,7 +1105,7 @@ export class TeamsMachine extends MachineCore {
1095
1105
  // screen override, so the window's own scrollback, wheel and selection work instead of the wheel arriving
1096
1106
  // in the pane as up/down arrows.
1097
1107
  : ['/usr/bin/env', 'TERM=screen-256color', spawnSync('which', ['tmux'], { encoding: 'utf8' }).stdout.trim() || 'tmux', ...(this.tmuxSocket ? ['-L', this.tmuxSocket] : []), '-T', 'RGB', 'attach-session', '-t', session.label ?? contextKey];
1098
- openPopup(terminal, attach, { ...this.env, TMUX: '' });
1108
+ return openPopup(terminal, attach, { ...this.env, TMUX: '' });
1099
1109
  }
1100
1110
 
1101
1111
  /**
@@ -1445,9 +1455,12 @@ export class TeamsMachine extends MachineCore {
1445
1455
  // hand here: a list that made a caller ask a second door "and is anything
1446
1456
  // wrong with these?" would be a list that knows and will not say.
1447
1457
  const rows = await this.attentionEvidence();
1458
+ // how many clients each pane has now (a popped window, a `supercode open`, a relay streaming it elsewhere): what a caller must
1459
+ // know before it opens another view of a pane on the same screen
1460
+ const viewers = this.#viewerCounts();
1448
1461
  // `title` is what a viewer should show: the override if one is set, else the tmux label.
1449
1462
  return rows.filter((row) => row.session.id && shown(row.session.contextKey)).map(({ session: s, attention, rank, notify, evidence }) => ({
1450
- pane: s.contextKey, dead: Boolean(s.dead), label: s.label, title: titles[`pane:${s.contextKey}`] ?? s.label, cwd: s.cwd, owned: s.owned, activeCommand: s.activeCommand, pid: s.rootPid ?? null, size: s.size, suspended: this.suspended.has(s.contextKey),
1463
+ pane: s.contextKey, dead: Boolean(s.dead), label: s.label, title: titles[`pane:${s.contextKey}`] ?? s.label, cwd: s.cwd, owned: s.owned, activeCommand: s.activeCommand, pid: s.rootPid ?? null, size: s.size, suspended: this.suspended.has(s.contextKey), viewers: viewers ? (viewers.get(s.label) ?? 0) : null,
1451
1464
  attention, rank, notify, evidence, input_cleanup: this.inputRepairs[s.contextKey] ? {pending:true,at:this.inputRepairs[s.contextKey].at,reason:this.inputRepairs[s.contextKey].reason} : null,
1452
1465
  }));
1453
1466
  }
@@ -1541,9 +1554,11 @@ export class TeamsMachine extends MachineCore {
1541
1554
  this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, ...executable, status, error: exit?.failed ? exit.how : undefined });
1542
1555
  return { key, pane: contextKey, reused: false, status, exit: exit?.how ?? null };
1543
1556
  }
1544
- if (params.popup !== false) this.#popupWindow(contextKey, session);
1557
+ // the window this machine's screen shows the pane in, when one opened: the caller is told, so it does not open a
1558
+ // second view of a pane that is already in front of the person at the machine
1559
+ const window = params.popup !== false && this.#popupWindow(contextKey, session);
1545
1560
  if (params.notify) this.#noticeWhenEnded({ key, pane: contextKey, notify: params.notify, label: params.name ?? params.kind ?? params.program });
1546
- return { key, pane: contextKey, reused: false, status: 'ready' };
1561
+ return { key, pane: contextKey, reused: false, status: 'ready', window };
1547
1562
  }
1548
1563
  case 'launches.get': {
1549
1564
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
@@ -1799,11 +1814,12 @@ export class TeamsMachine extends MachineCore {
1799
1814
  act('title', params.target, 'ok', renamed);
1800
1815
  return { ok: true, target: params.target, title: params.title ?? null, window: renamed };
1801
1816
  }
1802
- case 'workspace.inspect': case 'workspace.register': case 'workspace.list': case 'workspace.resolve': {
1817
+ case 'workspace.inspect': case 'workspace.register': case 'workspace.unregister': case 'workspace.list': case 'workspace.resolve': {
1803
1818
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1804
1819
  const registry = new Workspaces(this.home);
1805
1820
  const result = method === 'workspace.inspect' ? inspectCheckout(params.path)
1806
1821
  : method === 'workspace.register' ? registry.register(params.path, params.repository)
1822
+ : method === 'workspace.unregister' ? registry.unregister(params.path)
1807
1823
  : method === 'workspace.list' ? registry.list()
1808
1824
  : registry.resolve(params.repository, params.branch, { solo: params.solo, card: params.card, create: params.create });
1809
1825
  act('workspace', '*', 'ok', method);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.177",
3
+ "version": "0.2.179",
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",
package/workspaces.mjs CHANGED
@@ -25,18 +25,27 @@ export function inspectCheckout(path) {
25
25
  }
26
26
  export class Workspaces {
27
27
  constructor(home) { this.file = join(home, 'workspaces.jsonl'); }
28
- list() {
28
+ // Every registration stands until its checkout is registered again or unregistered; the newest standing row
29
+ // answers each repository and branch (list). Unregistering a registration made by mistake therefore gives back
30
+ // the one it replaced; the removal row stays in the file as the record of that act.
31
+ standing() {
29
32
  let text; try { text = readFileSync(this.file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
30
- const map = new Map();
33
+ let standing = [];
31
34
  for (const line of text.split('\n').filter(Boolean)) {
32
35
  let row; try { row = JSON.parse(line); } catch { throw new Error(`invalid machine workspace map ${this.file}`); }
33
- for (const [key, old] of map) {
34
- if (old.path === row.path) map.delete(key);
35
- else if (row.primary && old.repository === row.repository) old.primary = false;
36
- }
37
- map.set(`${row.repository}\n${row.branch ?? ''}`, row);
36
+ standing = standing.filter((old) => old.path !== row.path);
37
+ if (!row.removed) standing.push(row);
38
38
  }
39
- return [...map.values()];
39
+ return standing;
40
+ }
41
+ list() {
42
+ const map = new Map();
43
+ for (const row of this.standing()) { const key = `${row.repository}\n${row.branch ?? ''}`; map.delete(key); map.set(key, { ...row }); }
44
+ // A repository's main checkout is its newest registered main checkout that stands.
45
+ const rows = [...map.values()], main = new Map();
46
+ for (const row of rows) if (row.primary) main.set(row.repository, row);
47
+ for (const row of rows) row.primary = main.get(row.repository) === row;
48
+ return rows;
40
49
  }
41
50
  register(path, repository = null) {
42
51
  const info = inspectCheckout(path), id = repositoryId(repository ?? info.repository);
@@ -46,6 +55,16 @@ export class Workspaces {
46
55
  appendFileSync(this.file, JSON.stringify(row) + '\n', { mode: 0o600 });
47
56
  return row;
48
57
  }
58
+ // A registration made by mistake is removed: the checkout at `path` (its root, as registered) leaves the map. The
59
+ // checkout itself is untouched.
60
+ unregister(path) {
61
+ let at = resolve(path);
62
+ try { at = inspectCheckout(path).root; } catch { try { at = realpathSync(at); } catch { /* a path that no longer exists */ } }
63
+ const row = this.standing().findLast(r => r.path === at || r.path === resolve(path));
64
+ if (!row) throw new Error(`${path} is not registered in this machine's workspace map`);
65
+ appendFileSync(this.file, JSON.stringify({ path: row.path, removed: true, repository: row.repository, branch: row.branch }) + '\n', { mode: 0o600 });
66
+ return { ...row, removed: true };
67
+ }
49
68
  resolve(repository, branch = null, { solo = false, card = null, create = false } = {}) {
50
69
  const id = repositoryId(repository), rows = this.list().filter(r => r.repository === id);
51
70
  if (!rows.length) throw new Error(`repository ${id} is missing from this machine's workspace map; register its checkout with teams workspace register <path>`);