@volter/supercode-teams 0.2.120 → 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 +9 -1
- package/machine.mjs +55 -1
- package/package.json +3 -2
- package/workspaces.mjs +81 -0
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)
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
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
|
+
}
|