@volter/supercode-teams 0.2.119 → 0.2.121

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
@@ -164,7 +164,7 @@ async function main() {
164
164
  case 'describe':
165
165
  return withNode(async (rpc) => out(await rpc.call('describe')));
166
166
  case 'panes': {
167
- if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('panes.list')));
167
+ if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call(flags['presence-only'] ? 'panes.present' : 'panes.list')));
168
168
  if (sub === 'capture') return withNode(async (rpc) => out((await rpc.call('panes.capture', { pane: rest[0], lines: Number(flags.lines ?? 200) })).text));
169
169
  if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0] })));
170
170
  die('panes ls|capture|close');
@@ -229,6 +229,10 @@ async function main() {
229
229
  // `input <pane> <text>`: one delivery through the host's own sendInput; gated like control.
230
230
  // `--when-composer-empty`: a user's own turn, typed only into an empty composer
231
231
  // (answers `delivered: false` with the reason when it holds a draft).
232
+ if (flags['clear-draft']) {
233
+ if (rest.length || flags.key || flags['no-enter'] || flags['when-composer-empty']) die('input <pane> --clear-draft only clears the current draft; send a new message separately');
234
+ return withNode(async rpc => out(await rpc.call('panes.clearDraft', { pane: sub, caller: caller() })));
235
+ }
232
236
  if (flags.key !== undefined) {
233
237
  if (flags.key === true || rest.length) die('input <pane> --key NAMES: keys by tmux name, space- or comma-separated (Escape, Enter, Tab, BTab, BSpace, Space, Up, Down, Left, Right, Home, End, PPage, NPage, DC, C-a … C-z)');
234
238
  return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: keyBytes(String(flags.key)), submit: false, keys: true, caller: caller() })));
@@ -301,6 +305,10 @@ async function main() {
301
305
  return;
302
306
  }
303
307
  case 'workspace': {
308
+ if (sub === 'list') return withNode(async (rpc) => out(await rpc.call('workspace.list', {})));
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 === '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
+ if (sub === 'inspect' && rest[0]) return withNode(async (rpc) => out(await rpc.call('workspace.inspect', { path: rest[0] })));
304
312
  // `workspace move --from A --to B --path P [--to-path Q]`: the project leaves A and arrives
305
313
  // at B, published there only if every byte arrived (sha256 on commit) and neither machine
306
314
  // runs an exec job. A is left as it was; B's path must not exist.
package/machine.mjs CHANGED
@@ -30,6 +30,7 @@ import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readd
30
30
  import { join, resolve, dirname } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { homedir, hostname } from 'node:os';
33
+ import { Workspaces, inspectCheckout } from './workspaces.mjs';
33
34
  import { readMailHistory } from './mail-history.mjs';
34
35
  import { randomBytes, createHash } from 'node:crypto';
35
36
  import { WebSocket } from 'ws';
@@ -1287,6 +1288,18 @@ export class TeamsMachine extends MachineCore {
1287
1288
  }
1288
1289
  case 'describe':
1289
1290
  return this.describe();
1291
+ case 'panes.present': {
1292
+ // No harness callback: discovery may be the harness's own caller.
1293
+ const whole = operator || holds(admitted, 'view', '*');
1294
+ if (!whole && !observesPanes(admitted)) need('view', '*');
1295
+ const sessions = (await this.host.listSessions()).filter(s => s.contextKey && (whole || holds(admitted, 'observe', `pane:${s.contextKey}`)));
1296
+ return Promise.all(sessions.map(async session => {
1297
+ const screen = await this.host.capture(session.id, { lines: 100, preserveEscapes: true }).catch(() => null);
1298
+ const kind = this.#paneKind(session.contextKey, session);
1299
+ const composer = screen && terminal.composerText?.(kind, screen);
1300
+ return { pane: session.contextKey, idleComposer: composer != null && !/\besc to (?:interrupt|cancel)\b/iu.test(screen) };
1301
+ }));
1302
+ }
1290
1303
  case 'panes.list': {
1291
1304
  // A participant lists the panes its grants let it observe; view on the whole
1292
1305
  // machine (or operator) lists them all.
@@ -1400,7 +1413,10 @@ export class TeamsMachine extends MachineCore {
1400
1413
  need('control', `pane:${params.pane}`);
1401
1414
  for (const pending of this.pendingStartups.values()) if (pending.pane === params.pane) pending.abort.abort();
1402
1415
  const session = await this.#paneByContext(params.pane);
1403
- if (!session) throw new Error(`no pane with context ${params.pane}`);
1416
+ if (!session) {
1417
+ act('close', `pane:${params.pane}`, 'ok', 'already absent');
1418
+ return { closed: params.pane, alreadyAbsent: true };
1419
+ }
1404
1420
  await this.host.closeSession(session.id);
1405
1421
  act('close', `pane:${params.pane}`);
1406
1422
  return { closed: params.pane };
@@ -1454,6 +1470,34 @@ export class TeamsMachine extends MachineCore {
1454
1470
  act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', result.reason);
1455
1471
  return result;
1456
1472
  }
1473
+ case 'panes.clearDraft': {
1474
+ need('control', `pane:${params.pane}`);
1475
+ this.#refuseForeign(params.pane, await this.#callerOf(entry, params));
1476
+ const session = await this.#paneByContext(params.pane);
1477
+ if (!session) throw new Error(`no pane with context ${params.pane}`);
1478
+ if (this.suspended.has(params.pane)) throw new Error('pane is suspended; resume remote input first');
1479
+ if (this.inputInFlight.has(session.id)) return { cleared: false, reason: 'input-in-flight' };
1480
+ this.inputInFlight.add(session.id);
1481
+ try {
1482
+ const kind = this.#paneKind(params.pane, session);
1483
+ if (!['codex', 'claude-code'].includes(kind) || !terminal.composerText) return { cleared: false, reason: 'unsupported-composer' };
1484
+ const read = async () => {
1485
+ const screen = await this.host.capture(session.id, { lines: 200, preserveEscapes: true });
1486
+ return /\besc to (?:interrupt|cancel)\b/iu.test(screen) ? null : terminal.composerText(kind, screen);
1487
+ };
1488
+ const before = await read();
1489
+ await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
1490
+ if (before === null || before !== await read()) return { cleared: false, reason: 'composer-not-stable' };
1491
+ if (before === '') return { cleared: true, alreadyEmpty: true };
1492
+ // Explicit deletion, never submit. Readline end/kill-left clears the observed line;
1493
+ // a multiline or changed editor is reported unconfirmed, never sent as a turn.
1494
+ await this.host.sendInput(session.id, '\u0005\u0015', { submitMode: 'single' });
1495
+ await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
1496
+ const after = await read(), cleared = after === '';
1497
+ act('control', `pane:${params.pane}`, cleared ? 'draft-cleared' : 'draft-clear-unconfirmed');
1498
+ return { cleared, reason: cleared ? null : 'composer-not-empty', beforeHash: createHash('sha256').update(before).digest('hex') };
1499
+ } finally { this.inputInFlight.delete(session.id); }
1500
+ }
1457
1501
  case 'panes.turn': {
1458
1502
  // A user's own turn, as its own submitted turn: typed only into an empty
1459
1503
  // composer, read twice so a person who just started typing is not typed
@@ -1558,6 +1602,16 @@ export class TeamsMachine extends MachineCore {
1558
1602
  act('title', params.target, 'ok', renamed);
1559
1603
  return { ok: true, target: params.target, title: params.title ?? null, window: renamed };
1560
1604
  }
1605
+ case 'workspace.inspect': case 'workspace.register': case 'workspace.list': case 'workspace.resolve': {
1606
+ if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1607
+ const registry = new Workspaces(this.home);
1608
+ const result = method === 'workspace.inspect' ? inspectCheckout(params.path)
1609
+ : method === 'workspace.register' ? registry.register(params.path, params.repository)
1610
+ : method === 'workspace.list' ? registry.list()
1611
+ : registry.resolve(params.repository, params.branch, { solo: params.solo, card: params.card, create: params.create });
1612
+ act('workspace', '*', 'ok', method);
1613
+ return result;
1614
+ }
1561
1615
  case 'jobs.list': {
1562
1616
  // The execution jobs this daemon owns now (execution.mjs): a pilot changes its binding
1563
1617
  // only when the target it leaves and the one it takes run none.
package/mail-history.mjs CHANGED
@@ -40,7 +40,7 @@ function mailActors(rows, sessions, self) {
40
40
  return rows.map(({ envelope, session: to, direction, delivery }) => ({
41
41
  id: envelope.id, from: envelope.from, fromName: envelope.from_name, to,
42
42
  fromActor: actor(envelope.from), toActor: actor(to),
43
- text: envelope.body, at: envelope.created_at_ms, direction,
43
+ subject: envelope.subject || null, text: envelope.body, at: envelope.created_at_ms, direction,
44
44
  inReplyTo: envelope.in_reply_to ?? null, delivery: delivery?.state ?? 'unknown',
45
45
  }));
46
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.119",
3
+ "version": "0.2.121",
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",
@@ -32,7 +32,8 @@
32
32
  "./connector/native-continuation": {
33
33
  "types": "./connector/native-continuation.d.ts",
34
34
  "default": "./connector/native-continuation.mjs"
35
- }
35
+ },
36
+ "./workspaces": "./workspaces.mjs"
36
37
  },
37
38
  "bin": {
38
39
  "supercode-teams": "./bin/teams.mjs",
package/workspaces.mjs ADDED
@@ -0,0 +1,81 @@
1
+ // Each machine owns its repository/branch → checkout map. Cards carry only identity.
2
+ import { appendFileSync, readFileSync, mkdirSync, realpathSync, existsSync } from 'node:fs';
3
+ import { join, dirname, resolve } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ export { teamsHome } from '@volter/teams/home';
6
+
7
+ const git = (path, args, optional = false) => {
8
+ const r = spawnSync('git', ['-C', path, ...args], { encoding: 'utf8', timeout: 30_000 });
9
+ if (r.status !== 0) { if (optional) return null; throw new Error(`git ${args[0]} in ${path}: ${(r.stderr || r.error?.message || 'failed').trim()}`); }
10
+ return r.stdout.trim();
11
+ };
12
+ export function repositoryId(value) {
13
+ let s = String(value ?? '').trim().replace(/\.git\/?$/, '').replace(/\/$/, '');
14
+ if (/^[^/@]+@[^:]+:/.test(s)) s = s.replace(/^[^@]+@/, '').replace(':', '/');
15
+ else if (/^[a-z]+:\/\//i.test(s)) { const u = new URL(s); s = u.host + u.pathname; }
16
+ s = s.replace(/^github\.com\//i, '');
17
+ if (!/^[\w.-]+(?:\/[\w.-]+)+$/.test(s) || s.split('/').some(p => p === '.' || p === '..')) throw new Error('repository must be a git remote identity, for example volter-ai/editor; not a machine path');
18
+ return s;
19
+ }
20
+ export function inspectCheckout(path) {
21
+ const raw = git(path, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir']);
22
+ const [root, dir, common] = raw.split('\n').map(p => realpathSync(resolve(path, p)));
23
+ const remote = git(root, ['remote', 'get-url', 'origin'], true);
24
+ return { path: realpathSync(path), root, linked: dir !== common, repository: remote ? repositoryId(remote) : null, branch: git(root, ['branch', '--show-current']) || null };
25
+ }
26
+ export class Workspaces {
27
+ constructor(home) { this.file = join(home, 'workspaces.jsonl'); }
28
+ list() {
29
+ let text; try { text = readFileSync(this.file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
30
+ const map = new Map();
31
+ for (const line of text.split('\n').filter(Boolean)) {
32
+ 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);
38
+ }
39
+ return [...map.values()];
40
+ }
41
+ register(path, repository = null) {
42
+ const info = inspectCheckout(path), id = repositoryId(repository ?? info.repository);
43
+ if (info.repository && id !== info.repository) throw new Error(`checkout origin is ${info.repository}, not ${id}`);
44
+ const row = { repository: id, branch: info.branch, path: info.root, primary: !info.linked };
45
+ mkdirSync(dirname(this.file), { recursive: true });
46
+ appendFileSync(this.file, JSON.stringify(row) + '\n', { mode: 0o600 });
47
+ return row;
48
+ }
49
+ resolve(repository, branch = null, { solo = false, card = null, create = false } = {}) {
50
+ const id = repositoryId(repository), rows = this.list().filter(r => r.repository === id);
51
+ if (!rows.length) throw new Error(`repository ${id} is missing from this machine's workspace map; register its checkout with teams workspace register <path>`);
52
+ const exact = branch && rows.find(r => r.branch === branch);
53
+ const primary = rows.find(r => r.primary);
54
+ if (!solo) {
55
+ const row = branch ? exact : primary;
56
+ if (!row) throw new Error(`repository ${id}, branch ${branch ?? '(primary)'} is missing from this machine's workspace map`);
57
+ const facts = inspectCheckout(row.path);
58
+ if (facts.repository !== id || facts.branch !== row.branch) throw new Error(`workspace map for ${id} is stale; register the current checkout`);
59
+ return facts;
60
+ }
61
+ if (!primary) throw new Error(`repository ${id} has no main checkout registered on this machine for a solo worktree`);
62
+ const root = inspectCheckout(primary.path);
63
+ if (root.linked || root.repository !== id) throw new Error(`registered main checkout for ${id} is stale or already a linked worktree`);
64
+ if (!card || !/^[\w-]+$/.test(card)) throw new Error('solo worktree needs a card id');
65
+ const wanted = branch || `wt/${card}`;
66
+ if (!git(root.root, ['check-ref-format', '--branch', wanted], true)) throw new Error(`invalid worktree branch ${wanted}`);
67
+ const target = join(root.root, '.worktrees', card);
68
+ if (!existsSync(target) && create) {
69
+ mkdirSync(dirname(target), { recursive: true });
70
+ const exists = git(root.root, ['show-ref', '--verify', `refs/heads/${wanted}`], true);
71
+ const upstream = git(root.root, ['show-ref', '--verify', `refs/remotes/origin/${wanted}`], true);
72
+ git(root.root, ['worktree', 'add', ...(exists ? [] : ['-b', wanted]), target, ...(exists ? [wanted] : upstream ? [`refs/remotes/origin/${wanted}`] : [])]);
73
+ }
74
+ if (existsSync(target)) {
75
+ const facts = inspectCheckout(target);
76
+ if (!facts.linked || facts.repository !== id || facts.branch !== wanted) throw new Error(`card worktree ${target} does not match ${id}, branch ${wanted}`);
77
+ if (create) this.register(target, id);
78
+ }
79
+ return { repository: id, branch: wanted, path: target, root: root.root, linked: true };
80
+ }
81
+ }