@volter/supercode-teams 0.3.8 → 0.3.10
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 +43 -4
- package/client.mjs +11 -1
- package/machine-events.mjs +7 -3
- package/machine.mjs +17 -5
- package/package.json +2 -2
- package/server/node-upgrade.mjs +4 -1
- package/workspaces.mjs +3 -1
package/bin/teams.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { appendFileSync, createWriteStream, readFileSync, writeFileSync } from '
|
|
|
33
33
|
import { spawnSync } from 'node:child_process';
|
|
34
34
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
35
35
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
36
|
+
import { noteStep, slowLogPath, timeCommand } from '@volter/supercode-harness-sdk/slow-log';
|
|
36
37
|
|
|
37
38
|
// A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
|
|
38
39
|
if (process.env.SUPERCODE_TEAMS_LOG) {
|
|
@@ -49,9 +50,9 @@ import { moveWorkspace } from '../workspace-move.mjs';
|
|
|
49
50
|
import { teamsHome, paths, readJson, AccountLog, parseHostPort } from '@volter/teams/home';
|
|
50
51
|
import { NODE_PROTOCOL } from '@volter/teams/channel';
|
|
51
52
|
|
|
52
|
-
const MACHINE_VERBS = new Set(['machine', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail', 'health']);
|
|
53
|
+
const MACHINE_VERBS = new Set(['machine', 'slow', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail', 'health']);
|
|
53
54
|
// Every flag a machine verb reads; any other is refused rather than silently ignored.
|
|
54
|
-
const KNOWN_FLAGS = new Set(['home', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'key', 'listen', 'list', 'from', 'to', 'path', 'to-path', 'repo', 'branch', 'solo', 'card', 'create', 'presence-only', 'clear-draft', 'message-ids', 'json', 'all']);
|
|
55
|
+
const KNOWN_FLAGS = new Set(['home', 'since', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'key', 'listen', 'list', 'from', 'to', 'path', 'to-path', 'repo', 'branch', 'solo', 'card', 'create', 'presence-only', 'clear-draft', 'message-ids', 'json', 'all']);
|
|
55
56
|
|
|
56
57
|
const argv = process.argv.slice(2);
|
|
57
58
|
const flags = {};
|
|
@@ -68,8 +69,12 @@ for (let i = 0; i < argv.length; i += 1) {
|
|
|
68
69
|
} else positional.push(a);
|
|
69
70
|
}
|
|
70
71
|
const [verb, sub, ...rest] = positional;
|
|
72
|
+
// Each command is timed (the slow log, @volter/supercode-harness-sdk/slow-log); a service, a watch or a forward runs
|
|
73
|
+
// until stopped and is timed one door at a time instead.
|
|
74
|
+
const LONG_RUNNING = new Set(['connect', 'watch', 'port', 'stream', 'attach', 'serve']);
|
|
75
|
+
if (!LONG_RUNNING.has(verb) && !(verb === 'machine' && sub === 'start') && !(verb === 'message' && sub === 'watch')) timeCommand(`teams ${[verb, sub].filter(Boolean).join(' ')}`, argv);
|
|
71
76
|
|
|
72
|
-
const MACHINE_HELP = 'machine verbs: machine start|status, describe, panes, input, suspend, resume, files, ports, port, stream, jobs, workspace, title, attention, watch, rpc, launches, log, health, browser (add --machine NAME for an enrolled machine)';
|
|
77
|
+
const MACHINE_HELP = 'machine verbs: machine start|status|restart, slow [--since 1h], describe, panes, input, suspend, resume, files, ports, port, stream, jobs, workspace, title, attention, watch, rpc, launches, log, health, browser (add --machine NAME for an enrolled machine)';
|
|
73
78
|
|
|
74
79
|
if (verb === undefined || verb === 'help') {
|
|
75
80
|
process.stdout.write(`supercode teams: ${MACHINE_HELP}\n`);
|
|
@@ -109,7 +114,9 @@ function keyBytes(names) {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
async function machineChannel() {
|
|
112
|
-
|
|
117
|
+
const started = performance.now();
|
|
118
|
+
try { return await openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home }); }
|
|
119
|
+
finally { noteStep(flags.machine ? `open machine ${flags.machine}` : 'open the local machine', performance.now() - started); }
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
// The session (or board) this command acts for: the pane doors refuse a session acting on a pane it did not start.
|
|
@@ -176,6 +183,38 @@ async function main() {
|
|
|
176
183
|
if (sub === 'restart') return withNode(async (rpc) => out(await rpc.call('machine.restart', { reason: flags.reason ? String(flags.reason) : null })));
|
|
177
184
|
die('machine start|status|restart');
|
|
178
185
|
}
|
|
186
|
+
case 'slow': {
|
|
187
|
+
// `slow [--since 1h|30m|<iso>]`: the slow log on this machine, grouped by what was slow (kind and name), the
|
|
188
|
+
// slowest first, each with its count, median and longest time and its usual slowest step. The audit reads this.
|
|
189
|
+
const since = (() => {
|
|
190
|
+
const value = String(flags.since ?? '24h');
|
|
191
|
+
const relative = /^(\d+)([smhd])$/.exec(value);
|
|
192
|
+
if (relative) return Date.now() - Number(relative[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[relative[2]];
|
|
193
|
+
const at = Date.parse(value);
|
|
194
|
+
if (Number.isNaN(at)) die('slow --since takes 30m, 2h, 1d or a date');
|
|
195
|
+
return at;
|
|
196
|
+
})();
|
|
197
|
+
let text = '';
|
|
198
|
+
for (const file of [`${slowLogPath()}.1`, slowLogPath()]) {
|
|
199
|
+
try { text += readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
200
|
+
}
|
|
201
|
+
const groups = new Map();
|
|
202
|
+
for (const line of text.split('\n')) {
|
|
203
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
204
|
+
if (!(Date.parse(row.at) >= since)) continue;
|
|
205
|
+
const key = `${row.kind} ${row.name}`;
|
|
206
|
+
const group = groups.get(key) ?? { kind: row.kind, name: row.name, count: 0, ms: [], steps: new Map(), last: null };
|
|
207
|
+
group.count += 1; group.ms.push(row.ms); group.last = row.at;
|
|
208
|
+
if (row.slowest?.step) group.steps.set(row.slowest.step, (group.steps.get(row.slowest.step) ?? 0) + 1);
|
|
209
|
+
groups.set(key, group);
|
|
210
|
+
}
|
|
211
|
+
out([...groups.values()].map((group) => {
|
|
212
|
+
const sorted = group.ms.sort((a, b) => a - b);
|
|
213
|
+
const step = [...group.steps].sort((a, b) => b[1] - a[1])[0];
|
|
214
|
+
return { kind: group.kind, name: group.name, count: group.count, median_ms: sorted[Math.floor(sorted.length / 2)], max_ms: sorted.at(-1), usual_slowest_step: step ? step[0] : null, last: group.last };
|
|
215
|
+
}).sort((a, b) => b.max_ms * b.count - a.max_ms * a.count));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
179
218
|
case 'describe':
|
|
180
219
|
return withNode(async (rpc) => out(await rpc.call('describe')));
|
|
181
220
|
case 'panes': {
|
package/client.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
|
|
|
8
8
|
import { Channel } from '@volter/teams/channel';
|
|
9
9
|
import { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage, harnessTransport } from '@volter/teams/client';
|
|
10
10
|
import { lineSplitter } from '@volter/teams/lines';
|
|
11
|
+
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
11
12
|
|
|
12
13
|
export { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage, harnessTransport };
|
|
13
14
|
|
|
@@ -50,8 +51,17 @@ export async function execServerBridge(channel, harness, { host = '127.0.0.1' }
|
|
|
50
51
|
|
|
51
52
|
/** The node's own doors (describe, grants, launch, files, ports, attention, titles) over the `node` stream. */
|
|
52
53
|
export async function nodeRpc(channel) {
|
|
54
|
+
const opened = performance.now();
|
|
53
55
|
const stream = await channel.open('node');
|
|
54
|
-
|
|
56
|
+
noteStep('open the node stream', performance.now() - opened);
|
|
57
|
+
const rpc = new StreamRpc(stream);
|
|
58
|
+
// Each door call is a step of the running command (the slow log names the slowest).
|
|
59
|
+
const call = rpc.call.bind(rpc);
|
|
60
|
+
rpc.call = async (method, ...rest) => {
|
|
61
|
+
const started = performance.now();
|
|
62
|
+
try { return await call(method, ...rest); } finally { noteStep(`machine door ${method}`, performance.now() - started); }
|
|
63
|
+
};
|
|
64
|
+
return rpc;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
/**
|
package/machine-events.mjs
CHANGED
|
@@ -35,13 +35,17 @@ export class MachineEvents {
|
|
|
35
35
|
if(!response.ok||!response.headers.get('content-type')?.includes('text/event-stream'))throw new Error(`machine events returned HTTP ${response.status}, expected event stream`);
|
|
36
36
|
const reader=response.body.getReader();const decoder=new TextDecoder();let buffer='';
|
|
37
37
|
try{for(;;){const {value,done}=await reader.read();if(done)break;clearTimeout(deadline);deadline=setTimeout(()=>streamAbort.abort(new Error('machine event stream timed out')),45000);buffer+=decoder.decode(value,{stream:true});if(buffer.length>2*1024*1024)throw new Error('machine event exceeded stream bound');let boundary;while((boundary=buffer.indexOf('\n\n'))>=0){const frame=buffer.slice(0,boundary);buffer=buffer.slice(boundary+2);const data=frame.split('\n').filter(line=>line.startsWith('data:')).map(line=>line.slice(5).trimStart()).join('\n');if(data)await this.receive(JSON.parse(data));}}}finally{await reader.cancel().catch(()=>{});}
|
|
38
|
-
}catch(error){if(!this.abort.signal.aborted){if(this.error!==error.message)process.stderr.write(
|
|
38
|
+
}catch(error){if(!this.abort.signal.aborted){this.failingSince??=Date.now();if(this.error!==error.message)process.stderr.write(`${new Date().toISOString()} supercode teams machine events: ${error.message} (cursor ${this.state.cursor})\n`);this.error=error.message;}}
|
|
39
39
|
clearTimeout(deadline);this.connected=false;
|
|
40
40
|
if(!this.abort.signal.aborted)await new Promise(resolve=>{const done=()=>{clearTimeout(timer);this.abort.signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,5000);this.abort.signal.addEventListener('abort',done,{once:true});});
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
async receive(event){
|
|
44
|
-
if(event.type==='machine_ready'){
|
|
44
|
+
if(event.type==='machine_ready'){
|
|
45
|
+
// A stream back after a failure says so, with how long it was down: a notice that arrives late is then traceable.
|
|
46
|
+
if(this.failingSince)process.stderr.write(`${new Date().toISOString()} supercode teams machine events: stream back at cursor ${event.cursor} after ${Math.round((Date.now()-this.failingSince)/1000)} s of: ${this.error}\n`);
|
|
47
|
+
this.connected=true;this.error=null;this.failingSince=null;return;
|
|
48
|
+
}
|
|
45
49
|
if(event.type==='access_revoked')throw new Error('machine event subscription authorization revoked');
|
|
46
50
|
if(!['machine_snapshot','machine_linked','machine_unlinked'].includes(event.type))return;
|
|
47
51
|
if(!/^\d+$/.test(event.cursor??''))throw new Error('invalid machine event cursor');
|
|
@@ -74,6 +78,6 @@ export class MachineEvents {
|
|
|
74
78
|
child.stdout.on('data',chunk=>{detail=(detail+chunk).slice(-1000);});child.stderr.on('data',chunk=>{detail=(detail+chunk).slice(-1000);});
|
|
75
79
|
child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);let receipt;try{receipt=JSON.parse(detail.trim().split('\n').at(-1));}catch{}code===0&&receipt?.code===0?resolve():reject(new Error(receipt?.text||detail||`exit ${code}`));});
|
|
76
80
|
child.stdin.on('error',()=>{});
|
|
77
|
-
child.stdin.end(JSON.stringify({op:'send',from:`sc:${this.machine}:operator:teams-events`,from_name:'Teams machine events',to:item.to,id:item.id,queue:item.to.split(':')[2]==='board',
|
|
81
|
+
child.stdin.end(JSON.stringify({op:'send',from:`sc:${this.machine}:operator:teams-events`,from_name:'Teams machine events',to:item.to,id:item.id,queue:item.to.split(':')[2]==='board',notice:true,body:item.body}));
|
|
78
82
|
});}
|
|
79
83
|
}
|
package/machine.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composer
|
|
|
44
44
|
// than failing the load: a POSIX daemon never needs it. Windows needs a supercode-terminal with PtyTerminalHost.
|
|
45
45
|
import * as terminal from '@volter/supercode-terminal';
|
|
46
46
|
import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
|
|
47
|
+
import { recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
47
48
|
import { lineSplitter } from '@volter/teams/lines';
|
|
48
49
|
import { NODE_PROTOCOL } from '@volter/teams/channel';
|
|
49
50
|
import { holds, observesPanes } from '@volter/teams/admission';
|
|
@@ -235,7 +236,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
235
236
|
}, 1000);
|
|
236
237
|
this.terminalMailTimer.unref?.();
|
|
237
238
|
// What actually runs under each hosted pane, for the trace pack (process-tree.mjs): one read every five seconds.
|
|
238
|
-
|
|
239
|
+
// It needs each pane's root pid only: the pane listing, not attention's screen capture of every pane.
|
|
240
|
+
this.processTree = new ProcessTree({ root: join(this.home, 'processes'), panes: async () => (await this.host.listSessions()).map((session) => ({ pane: session.contextKey, rootPid: session.rootPid })) });
|
|
239
241
|
this.processTimer = setInterval(() => {
|
|
240
242
|
if (this.processWork) return;
|
|
241
243
|
this.processWork = this.processTree.sample().catch(error => this.emit('mail-watch-stderr', `process tree: ${error.message}`)).finally(() => { this.processWork = null; });
|
|
@@ -959,7 +961,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
959
961
|
const evidence = {
|
|
960
962
|
liveness: own?.liveness ?? livenessOf(session.activeCommand),
|
|
961
963
|
signal: classifyAttentionSignal(capture) ?? own?.signal ?? null,
|
|
962
|
-
|
|
964
|
+
// tmux keeps when each pane last printed, so idle time advances between queries; a host that does not say
|
|
965
|
+
// (the Windows pane server) falls back to the screen's own plateau since this daemon last looked.
|
|
966
|
+
idleSeconds: Number.isFinite(session.lastOutputAt) ? Math.max(0, Math.round((now - session.lastOutputAt) / 1000)) : this.#idleSeconds(session.id, capture, now),
|
|
963
967
|
requested,
|
|
964
968
|
...(own ? { livenessSource: 'cooperative' } : {}),
|
|
965
969
|
};
|
|
@@ -1044,9 +1048,15 @@ export class TeamsMachine extends MachineCore {
|
|
|
1044
1048
|
}
|
|
1045
1049
|
|
|
1046
1050
|
async #drainTerminalMail() {
|
|
1047
|
-
|
|
1051
|
+
// One pane listing per tick, shared by every mailbox: a listing per mailbox each second was most of this
|
|
1052
|
+
// daemon's tmux spawns (8 retained mailboxes, about 70 tmux processes a second, t_23e47b73).
|
|
1053
|
+
const retained = retainedTerminalMail(this.terminalMailRoot);
|
|
1054
|
+
if (!retained.length && !this.terminalMailboxes.size) return;
|
|
1055
|
+
const panes = await this.host.listSessions();
|
|
1056
|
+
const paneByContext = (contextKey) => panes.find((s) => s.contextKey === contextKey) ?? null;
|
|
1057
|
+
for (const saved of retained) {
|
|
1048
1058
|
if (this.terminalMailboxes.has(saved.recipient) || !saved.binding) continue;
|
|
1049
|
-
const session =
|
|
1059
|
+
const session = paneByContext(saved.binding.context);
|
|
1050
1060
|
if (!session || session.id !== saved.binding.terminal || session.rootPid !== saved.binding.pid) continue;
|
|
1051
1061
|
const queue = new TerminalMailbox(this.terminalMailRoot, saved.recipient, receipt => this.log.append({ principal: null, cap: 'control',
|
|
1052
1062
|
target: `pane:${saved.binding.context}`, outcome: receipt.delivered ? 'input-delivered' : 'input-pending', detail: JSON.stringify(receipt) }), (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
|
|
@@ -1057,7 +1067,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1057
1067
|
// exists. Its mail stays retained: the session's next pane takes it up.
|
|
1058
1068
|
for (const [recipient, queue] of this.terminalMailboxes) {
|
|
1059
1069
|
const binding = queue.state.binding;
|
|
1060
|
-
const live = binding ?
|
|
1070
|
+
const live = binding ? paneByContext(binding.context) : null;
|
|
1061
1071
|
if (!live || live.id !== binding.terminal) this.terminalMailboxes.delete(recipient);
|
|
1062
1072
|
}
|
|
1063
1073
|
await Promise.all([...this.terminalMailboxes.values()].filter(queue => !this.inputRepairs[queue.state.binding?.context] && !this.inputInFlight.has(queue.session?.id)).map(queue => queue.kick()));
|
|
@@ -1497,8 +1507,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
1497
1507
|
const result = await withTimeout(this.#dispatch(entry, request.method, request.params ?? {}), bound, request.method);
|
|
1498
1508
|
reply({ id: request.id, result });
|
|
1499
1509
|
this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: 'ok' });
|
|
1510
|
+
recordSlow({ kind: 'door', name: request.method, ms: Date.now() - startedAt, slowest: null, extra: { principal: entry.principal ?? null } });
|
|
1500
1511
|
} catch (error) {
|
|
1501
1512
|
this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: error.code ?? 'error', detail: error.message });
|
|
1513
|
+
recordSlow({ kind: 'door', name: request.method, ms: Date.now() - startedAt, outcome: String(error.code ?? 'error'), slowest: null, extra: { principal: entry.principal ?? null } });
|
|
1502
1514
|
reply({ id: request.id, error: { code: error.code ?? -1, message: error.message } });
|
|
1503
1515
|
}
|
|
1504
1516
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
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",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@volter/supercode-health": "^0.1.0",
|
|
67
67
|
"@volter/supercode-terminal": "^0.2.54",
|
|
68
68
|
"@volter/supercode-trace": "^0.1.0",
|
|
69
|
-
"@volter/teams": "0.2.
|
|
69
|
+
"@volter/teams": "0.2.7",
|
|
70
70
|
"@xterm/addon-serialize": "^0.14.0",
|
|
71
71
|
"@xterm/headless": "^6.0.0",
|
|
72
72
|
"protobufjs": "8.8.0",
|
package/server/node-upgrade.mjs
CHANGED
|
@@ -22,11 +22,14 @@ export function createMachineUpgradeHandler(api, { authenticate }) {
|
|
|
22
22
|
socket.end(`HTTP/1.1 ${status} ${code}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`);
|
|
23
23
|
};
|
|
24
24
|
const handler = async (req, socket, head) => {
|
|
25
|
+
// A caller that drops its connection mid-handshake (or later) ends only its own socket: an unheard socket error
|
|
26
|
+
// would end the whole server (measured: a client's ECONNRESET during authorization stopped the Node host).
|
|
27
|
+
socket.on('error', () => socket.destroy());
|
|
25
28
|
try {
|
|
26
29
|
const decision = await authorizeMachineUpgrade(api, { authenticate, url: req.url, headers: req.headers });
|
|
27
30
|
if (decision.status) return refuse(socket, decision.status, decision.code, decision.message);
|
|
28
31
|
return wss.handleUpgrade(req, socket, head, (ws) => {
|
|
29
|
-
ws.alive = true; ws.on('pong', () => { ws.alive = true; });
|
|
32
|
+
ws.alive = true; ws.on('pong', () => { ws.alive = true; }); ws.on('error', () => ws.terminate());
|
|
30
33
|
void acceptMachineSocket(api, decision, createWebSocketStream(ws), (reason) => ws.close(1000, String(reason ?? 'closed').slice(0, 100)));
|
|
31
34
|
});
|
|
32
35
|
} catch (error) {
|
package/workspaces.mjs
CHANGED
|
@@ -20,7 +20,9 @@ export function repositoryId(value) {
|
|
|
20
20
|
export function inspectCheckout(path) {
|
|
21
21
|
const raw = git(path, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir']);
|
|
22
22
|
const [root, dir, common] = raw.split('\n').map(p => realpathSync(resolve(path, p)));
|
|
23
|
-
|
|
23
|
+
// The repository the checkout declares (remote.origin.url), not where a URL rewrite (insteadOf: a mirror, a proxy, a
|
|
24
|
+
// World's twin) sends its transport.
|
|
25
|
+
const remote = git(root, ['config', '--get', 'remote.origin.url'], true);
|
|
24
26
|
return { path: realpathSync(path), root, linked: dir !== common, repository: remote ? repositoryId(remote) : null, branch: git(root, ['branch', '--show-current']) || null };
|
|
25
27
|
}
|
|
26
28
|
export class Workspaces {
|