gitroots 0.0.0-stage → 0.6.0
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 +137 -2
- package/assets/logo.png +0 -0
- package/bin/gitroots.js +19 -0
- package/package.json +36 -4
- package/src/agents/local.js +219 -0
- package/src/api.js +84 -0
- package/src/cli.js +76 -0
- package/src/commands/completion.js +46 -0
- package/src/commands/push.js +207 -0
- package/src/commands/update.js +69 -0
- package/src/config.js +66 -0
- package/src/git/checks.js +96 -0
- package/src/installer.js +96 -0
- package/src/rules.js +88 -0
- package/src/runtime.js +63 -0
- package/src/session/account.js +133 -0
- package/src/session/agents.js +416 -0
- package/src/session/helpers.js +71 -0
- package/src/session/logo.js +40 -0
- package/src/session/registry.js +61 -0
- package/src/session/repl.js +332 -0
- package/src/session/setup.js +101 -0
- package/src/session/workspace.js +270 -0
- package/src/ui/ansi.js +138 -0
- package/src/ui/components.js +85 -0
- package/src/ui/term.js +298 -0
package/src/runtime.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Where gitroots is running from: an npm/source install (node + bin/gitroots.js)
|
|
2
|
+
// or a standalone binary (Node single executable application) from install.sh.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
export const VERSION = '0.6.0';
|
|
9
|
+
|
|
10
|
+
// Default download location for install.sh / gitroots update. Override with GITROOTS_DOWNLOAD_BASE.
|
|
11
|
+
export const DOWNLOAD_BASE = process.env.GITROOTS_DOWNLOAD_BASE || 'https://gitroots.com/cli';
|
|
12
|
+
export const NPM_PACKAGE = 'gitroots';
|
|
13
|
+
|
|
14
|
+
let sea = null;
|
|
15
|
+
try { sea = typeof process.getBuiltinModule === 'function' ? process.getBuiltinModule('node:sea') : null; } catch { sea = null; }
|
|
16
|
+
|
|
17
|
+
export const isBinary = Boolean(sea && typeof sea.isSea === 'function' && sea.isSea());
|
|
18
|
+
|
|
19
|
+
function here(rel) { return fileURLToPath(new URL(rel, import.meta.url)); }
|
|
20
|
+
|
|
21
|
+
export function asset(name) {
|
|
22
|
+
if (isBinary) return Buffer.from(sea.getAsset(name));
|
|
23
|
+
return fs.readFileSync(here(`../assets/${name}`));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Native install layout (same idea as Claude Code's native installer):
|
|
27
|
+
// ~/.local/share/gitroots/versions/<version>/gitroots the binaries
|
|
28
|
+
// ~/.local/bin/gitroots symlink to the current one
|
|
29
|
+
export function nativePaths() {
|
|
30
|
+
const home = os.homedir();
|
|
31
|
+
if (process.platform === 'win32') {
|
|
32
|
+
const base = path.join(home, '.local');
|
|
33
|
+
return { data: path.join(base, 'share', 'gitroots'), bin: path.join(base, 'bin'), launcher: path.join(base, 'bin', 'gitroots.exe') };
|
|
34
|
+
}
|
|
35
|
+
const data = process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'gitroots') : path.join(home, '.local', 'share', 'gitroots');
|
|
36
|
+
return { data, bin: path.join(home, '.local', 'bin'), launcher: path.join(home, '.local', 'bin', 'gitroots') };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function installMethod() {
|
|
40
|
+
if (isBinary) return 'native';
|
|
41
|
+
const bin = here('../bin/gitroots.js');
|
|
42
|
+
if (/[\\/]node_modules[\\/]/.test(bin)) return 'npm';
|
|
43
|
+
return 'source';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The command that runs this gitroots, for hooks: [executable, ...args]
|
|
47
|
+
export function selfCommand() {
|
|
48
|
+
if (isBinary) {
|
|
49
|
+
const { launcher } = nativePaths();
|
|
50
|
+
return [fs.existsSync(launcher) ? launcher : process.execPath];
|
|
51
|
+
}
|
|
52
|
+
return [process.execPath, here('../bin/gitroots.js')];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function platformKey() {
|
|
56
|
+
const os_ = process.platform === 'win32' ? 'win32' : process.platform;
|
|
57
|
+
const arch = process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : process.arch;
|
|
58
|
+
let musl = false;
|
|
59
|
+
if (os_ === 'linux') {
|
|
60
|
+
try { musl = !process.report.getReport().header.glibcVersionRuntime; } catch { musl = false; }
|
|
61
|
+
}
|
|
62
|
+
return `${os_}-${arch}${musl ? '-musl' : ''}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// /login · /logout · /whoami · /workspace
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { Api, ApiError } from '../api.js';
|
|
5
|
+
import { DEFAULT_SERVER, readConfig, writeConfig, updateConfig, saveToken, forgetToken } from '../config.js';
|
|
6
|
+
import { select, interactive, spinner, sleep } from '../ui/term.js';
|
|
7
|
+
import { json } from '../ui/components.js';
|
|
8
|
+
import { c, line, pad, next, needSignIn, load, cmdText } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
function openBrowser(url) {
|
|
11
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
12
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
13
|
+
try {
|
|
14
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
15
|
+
child.on('error', () => {});
|
|
16
|
+
child.unref();
|
|
17
|
+
return true;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function pickWorkspace(ctx, workspaces, current) {
|
|
22
|
+
if (!workspaces.length) return null;
|
|
23
|
+
if (workspaces.length === 1 || !interactive() || ctx.json) return workspaces.find((w) => w.id === current) || workspaces[0];
|
|
24
|
+
line(`${c.i(c.b('?'))} Which workspace?`);
|
|
25
|
+
const start = Math.max(0, workspaces.findIndex((w) => w.id === current));
|
|
26
|
+
const res = await select({
|
|
27
|
+
count: workspaces.length,
|
|
28
|
+
initial: start,
|
|
29
|
+
render: (i) => workspaces.map((w, j) => `${j === i ? c.g('❯ ') : ' '}${j === i ? c.b(pad(w.name, 32)) : pad(w.name, 32)}${c.m(`${w.slug}${w.role ? ` · ${w.role}` : ''}`)}`).join('\n'),
|
|
30
|
+
});
|
|
31
|
+
if (res.action === 'cancel') return null;
|
|
32
|
+
return workspaces[res.index];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function roleOf(ws, me) {
|
|
36
|
+
const m = (me.memberships || []).find((x) => x.organization === ws.id);
|
|
37
|
+
return m ? m.role : ws.role_flags?.is_admin ? 'admin' : 'member';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function workspaceList(me) {
|
|
41
|
+
return (me.workspaces || []).map((w) => ({ id: w.id, name: w.name, slug: w.slug, role: roleOf(w, me) }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function login(ctx, args, flags) {
|
|
45
|
+
const server = String(flags.server || readConfig().server || DEFAULT_SERVER).replace(/\/+$/, '');
|
|
46
|
+
const anon = new Api({ server });
|
|
47
|
+
const start = await load(ctx, `Contacting ${anon.host}…`, () => anon.authStart(`gitroots CLI on ${os.hostname().replace(/\.local$/, '')}`));
|
|
48
|
+
line();
|
|
49
|
+
line(` Sign in by approving this code in your browser:`);
|
|
50
|
+
line();
|
|
51
|
+
line(` ${c.b(c.g(start.user_code))}`);
|
|
52
|
+
line();
|
|
53
|
+
const opened = !flags['no-browser'] && interactive() && openBrowser(start.verification_url);
|
|
54
|
+
line(` ${opened ? c.m('Opened') : c.m('Open')} ${c.i(start.verification_url)}`);
|
|
55
|
+
line(` ${c.m('Check the code matches, then press Approve. ctrl-c cancels.')}`);
|
|
56
|
+
line();
|
|
57
|
+
|
|
58
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
59
|
+
const sp = spinner((f) => `${c.i(f)} ${c.s('Waiting for approval in the browser…')}`);
|
|
60
|
+
let result = null;
|
|
61
|
+
try {
|
|
62
|
+
while (Date.now() < deadline) {
|
|
63
|
+
if (ctx.signal?.aborted) throw new ApiError('sign-in cancelled', 0);
|
|
64
|
+
const r = await anon.authPoll(start.device_code, ctx.signal);
|
|
65
|
+
if (r.status === 200) { result = r.data; break; }
|
|
66
|
+
if (r.status !== 202) throw new ApiError(r.data?.detail || 'sign-in failed', r.status);
|
|
67
|
+
const t0 = Date.now();
|
|
68
|
+
while (Date.now() - t0 < (start.interval || 2) * 1000) { if (ctx.signal?.aborted) break; await sleep(100); }
|
|
69
|
+
}
|
|
70
|
+
} finally { sp.stop(); }
|
|
71
|
+
if (!result) throw new ApiError(`the code expired · run ${cmdText(ctx, 'login')} again`, 410);
|
|
72
|
+
|
|
73
|
+
const user = result.user;
|
|
74
|
+
const where = saveToken(server, user.username, result.token);
|
|
75
|
+
const api = new Api({ server, token: result.token });
|
|
76
|
+
const me = await load(ctx, 'Loading your workspaces…', () => api.me());
|
|
77
|
+
const prev = readConfig();
|
|
78
|
+
const ws = await pickWorkspace(ctx, workspaceList(me), prev.org?.id);
|
|
79
|
+
writeConfig({ server, username: user.username, user: { id: user.id, name: user.name, email: user.email }, org: ws, signedInAt: new Date().toISOString() });
|
|
80
|
+
ctx.reload();
|
|
81
|
+
if (ctx.json) { json({ user, org: ws, server }); return 0; }
|
|
82
|
+
line(`${c.g('✓')} Signed in as ${c.b(user.name || user.username)} ${c.m(`<${user.email}>`)}${ws ? ` ${c.m('·')} workspace ${c.b(ws.name)} ${c.m(`(${ws.role})`)}` : ''}`);
|
|
83
|
+
line(` ${c.m(`token kept in ${where === 'keychain' ? 'the macOS Keychain' : '~/.gitroots/credentials.json (only you can read it)'}`)}`);
|
|
84
|
+
if (!ws) line(` ${c.w('◐ you are not in a workspace yet · create or join one on gitroots.com')}`);
|
|
85
|
+
next(ctx, 'status');
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function logout(ctx) {
|
|
90
|
+
const cfg = readConfig();
|
|
91
|
+
if (!cfg.username) { line(c.m('○ not signed in')); return 0; }
|
|
92
|
+
forgetToken(cfg.server, cfg.username);
|
|
93
|
+
writeConfig({ server: cfg.server });
|
|
94
|
+
ctx.reload();
|
|
95
|
+
line(`${c.g('✓')} Signed out ${c.b(cfg.username)} on this computer ${c.m(`· ${cfg.server}`)}`);
|
|
96
|
+
line(` ${c.m('The token is shared with the gitroots app; revoke it under Settings › Account › Sessions on gitroots.com.')}`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function whoami(ctx) {
|
|
101
|
+
needSignIn(ctx);
|
|
102
|
+
const me = await load(ctx, 'Checking your account…', () => ctx.api.me({ signal: ctx.signal }));
|
|
103
|
+
const cfg = readConfig();
|
|
104
|
+
const wss = workspaceList(me);
|
|
105
|
+
if (ctx.json) { json({ user: me.user, server: ctx.api.server, workspace: cfg.org, workspaces: wss }); return 0; }
|
|
106
|
+
const u = me.user;
|
|
107
|
+
line(`${c.g(c.b('gitroots'))} ${c.m('·')} ${c.b(u.display_name || u.username)} ${c.m(`<${u.email}>`)}${u.is_staff ? c.m(' · staff') : ''}`);
|
|
108
|
+
line(` ${c.s(pad('server', 11))}${ctx.api.server}`);
|
|
109
|
+
line(` ${c.s(pad('workspace', 11))}${cfg.org ? `${c.b(cfg.org.name)} ${c.m(`(${cfg.org.role})`)}` : c.m('none')}`);
|
|
110
|
+
if (wss.length > 1) line(` ${c.s(pad('also in', 11))}${wss.filter((w) => w.id !== cfg.org?.id).map((w) => w.name).join(', ')} ${c.g(`→ ${cmdText(ctx, 'workspace')}`)}`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function workspace(ctx, args) {
|
|
115
|
+
needSignIn(ctx);
|
|
116
|
+
const me = await load(ctx, 'Loading your workspaces…', () => ctx.api.me({ signal: ctx.signal }));
|
|
117
|
+
const wss = workspaceList(me);
|
|
118
|
+
if (!wss.length) { line(c.w('◐ you are not in a workspace yet · create or join one on gitroots.com')); return 1; }
|
|
119
|
+
let ws;
|
|
120
|
+
if (args[0]) {
|
|
121
|
+
ws = wss.find((w) => w.slug === args[0] || w.name.toLowerCase() === args.join(' ').toLowerCase() || String(w.id) === args[0]);
|
|
122
|
+
if (!ws) throw new Error(`no workspace "${args.join(' ')}" · yours: ${wss.map((w) => w.slug).join(', ')}`);
|
|
123
|
+
} else {
|
|
124
|
+
ws = await pickWorkspace(ctx, wss, readConfig().org?.id);
|
|
125
|
+
if (!ws) return 1;
|
|
126
|
+
}
|
|
127
|
+
updateConfig({ org: ws });
|
|
128
|
+
ctx.reload();
|
|
129
|
+
if (ctx.json) { json(ws); return 0; }
|
|
130
|
+
line(`${c.g('✓')} Workspace ${c.b(ws.name)} ${c.m(`(${ws.role})`)}`);
|
|
131
|
+
next(ctx, 'status');
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// /agents · /machines · /watch · /attach
|
|
2
|
+
// Sessions come from two places:
|
|
3
|
+
// - your machines, as the gitroots app reports them (approve / deny / message / stop go back through gitroots)
|
|
4
|
+
// - this computer: Claude Code and Codex session files (resume them here)
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { select, interactive, ask, confirm, enterFullscreen, leaveFullscreen, paintFrame, size, parseKeys, FRAMES } from '../ui/term.js';
|
|
7
|
+
import { json } from '../ui/components.js';
|
|
8
|
+
import { localSessions, refreshLocal, shortPath, ago as agoMs } from '../agents/local.js';
|
|
9
|
+
import { c, h, line, pad, fit, width, next, load, ago, empty, cols, cmdText, statusMark } from './helpers.js';
|
|
10
|
+
import { windowed } from './workspace.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------- data
|
|
13
|
+
|
|
14
|
+
function remoteRow(s) {
|
|
15
|
+
return {
|
|
16
|
+
key: `r:${s.id}`, source: 'remote', id: s.id, agentName: s.agent_name, title: s.title, where: s.machine || 'removed machine',
|
|
17
|
+
branch: [s.repo, s.branch].filter(Boolean).join(' · '), status: s.status, needs: s.needs_you, updated: ago(s.updated_at), raw: s,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function localRow(s) {
|
|
22
|
+
return {
|
|
23
|
+
key: `l:${s.id}`, source: 'local', id: s.id, agentName: s.agentName, title: s.title, where: shortPath(s.cwd),
|
|
24
|
+
branch: s.branch, status: s.status, needs: false, updated: agoMs(s.mtime), raw: s,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function gather(ctx, { quiet = false } = {}) {
|
|
29
|
+
let remote = null;
|
|
30
|
+
let remoteError = null;
|
|
31
|
+
if (ctx.api.signedIn && ctx.api.orgId !== null && ctx.api.orgId !== undefined) {
|
|
32
|
+
try { remote = await ctx.api.agents({ signal: ctx.signal, timeout: 8000 }); } catch (e) { remoteError = e.message; }
|
|
33
|
+
} else remoteError = ctx.api.signedIn ? 'no workspace chosen' : 'not signed in';
|
|
34
|
+
const local = localSessions({ limit: 40 });
|
|
35
|
+
const rows = [
|
|
36
|
+
...(remote?.sessions || []).filter((s) => s.active || Date.now() - Date.parse(s.updated_at || 0) < 864e5).map(remoteRow),
|
|
37
|
+
...local.map(localRow),
|
|
38
|
+
];
|
|
39
|
+
rows.sort((a, b) => (b.needs - a.needs) || (rank(a.status) - rank(b.status)));
|
|
40
|
+
if (!quiet && remoteError && ctx.api.signedIn) line(c.w(`◐ couldn't load sessions from gitroots: ${remoteError}`));
|
|
41
|
+
return { rows, remote, local, remoteError };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rank = (s) => ({ waiting: 0, blocked: 1, live: 2, idle: 3 }[s] ?? 4);
|
|
45
|
+
|
|
46
|
+
function rowText(r, sel, w = cols()) {
|
|
47
|
+
const tailFit = (t, n) => (t.length > n ? `…${t.slice(t.length - n + 1)}` : t);
|
|
48
|
+
const where = r.source === 'remote' ? `⇄ ${r.where}` : `⌂ ${tailFit(r.where, 28)}`;
|
|
49
|
+
const text = `${pad(statusMark(r.status), 14)}${pad(r.agentName, 13)}${fit(r.title, 36)} ${c.m(fit(where, 30))} ${c.m(fit(r.branch || '', Math.max(8, w - 111)))}${c.m(pad(r.updated, 9))}`;
|
|
50
|
+
return `${sel ? c.g('❯ ') : ' '}${sel ? c.b(text) : text}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------- /agents
|
|
54
|
+
|
|
55
|
+
export async function agents(ctx) {
|
|
56
|
+
const d = await load(ctx, 'Looking for agent sessions…', () => gather(ctx, { quiet: true }));
|
|
57
|
+
if (ctx.json) { json({ remote: d.remote?.sessions || [], local: d.local.map(({ log, ...s }) => s) }); return 0; }
|
|
58
|
+
if (d.remoteError && ctx.api.signedIn) line(c.w(`◐ couldn't load sessions from gitroots: ${d.remoteError}`));
|
|
59
|
+
if (!ctx.api.signedIn) line(c.m(`○ showing this computer only · ${cmdText(ctx, 'login')} adds the sessions your gitroots app reports`));
|
|
60
|
+
if (!d.rows.length) { empty('no agent sessions in the last 14 days'); return 0; }
|
|
61
|
+
line(` ${h(`${pad('STATE', 14)}${pad('AGENT', 13)}${pad('SESSION', 38)}${pad('WHERE', 32)}BRANCH`)}`);
|
|
62
|
+
if (!ctx.session || !interactive()) {
|
|
63
|
+
for (const r of d.rows.slice(0, 30)) line(rowText(r, false));
|
|
64
|
+
line();
|
|
65
|
+
next(ctx, 'watch');
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
const res = await select({ count: d.rows.length, render: (i) => `${windowed(d.rows, i, 12, (r, sel) => rowText(r, sel))}\n${c.m(' ⇄ your machines, via the gitroots app ⌂ this computer')}` });
|
|
69
|
+
if (res.action === 'cancel') return 0;
|
|
70
|
+
return sessionActions(ctx, d.rows[res.index]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function sessionActions(ctx, r) {
|
|
74
|
+
const s = r.raw;
|
|
75
|
+
line();
|
|
76
|
+
line(`${c.b(r.title)} ${statusMark(r.status)}`);
|
|
77
|
+
line(` ${c.s(pad('agent', 10))}${r.agentName} ${c.m(r.source === 'remote' ? `on ${r.where} · owner ${s.owner}` : `on this computer · ${r.where}`)}`);
|
|
78
|
+
if (r.branch) line(` ${c.s(pad('branch', 10))}${r.branch}`);
|
|
79
|
+
if (r.source === 'remote' && s.ask && s.ask.what) {
|
|
80
|
+
line(` ${c.w(pad('asks', 10))}${s.ask.what}${s.ask.cmd ? c.m(` · ${s.ask.cmd}`) : ''}`);
|
|
81
|
+
if (s.ask.rule) line(` ${c.s(pad('rule', 10))}${s.ask.rule}`);
|
|
82
|
+
}
|
|
83
|
+
line();
|
|
84
|
+
const actions = [['view', 'Open live view']];
|
|
85
|
+
if (r.source === 'remote') {
|
|
86
|
+
if (s.can_decide) actions.push(['approve', 'Approve'], ['deny', 'Deny']);
|
|
87
|
+
if (s.can_send) actions.push(['send', 'Send a message']);
|
|
88
|
+
if (s.can_stop) actions.push(['stop', 'Stop']);
|
|
89
|
+
} else {
|
|
90
|
+
actions.push(['resume', `Resume here (${s.resume.cmd} ${s.resume.args.join(' ')})`], ['copy', 'Copy the resume command']);
|
|
91
|
+
}
|
|
92
|
+
actions.push(['back', 'Back']);
|
|
93
|
+
const pick = await select({ count: actions.length, render: (i) => actions.map(([, label], j) => `${j === i ? c.g('❯ ') : ' '}${j === i ? c.b(label) : label}`).join('\n') });
|
|
94
|
+
if (pick.action === 'cancel') return 0;
|
|
95
|
+
const act = actions[pick.index][0];
|
|
96
|
+
if (act === 'back') return 0;
|
|
97
|
+
if (act === 'view') return attachView(ctx, r);
|
|
98
|
+
if (act === 'resume') return resumeLocal(s);
|
|
99
|
+
if (act === 'copy') return copyResume(s);
|
|
100
|
+
return remoteAction(ctx, s, act);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function remoteAction(ctx, s, act) {
|
|
104
|
+
let text = '';
|
|
105
|
+
if (act === 'send') { text = await ask('Message to the agent'); if (!text) { line(c.m(' nothing sent')); return 0; } }
|
|
106
|
+
if (act === 'stop' && !(await confirm(`Stop ${s.title}?`, { def: false }))) { line(c.m(' nothing changed')); return 0; }
|
|
107
|
+
const r = await load(ctx, 'Sending to gitroots…', () => ctx.api.agentAction(s.id, act, text, { signal: ctx.signal }));
|
|
108
|
+
const verb = { approve: 'Approved', deny: 'Denied', send: 'Message sent', stop: 'Stop requested' }[act];
|
|
109
|
+
line(`${act === 'deny' || act === 'stop' ? c.w('◐') : c.g('✓')} ${verb} ${c.m(`· ${r.note || 'the gitroots app on that machine picks it up on its next sync'}`)}`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasCommand(cmd) {
|
|
114
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'sh', process.platform === 'win32' ? [cmd] : ['-c', `command -v ${cmd}`], { stdio: 'ignore' });
|
|
115
|
+
return r.status === 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function resumeLocal(s) {
|
|
119
|
+
if (!hasCommand(s.resume.cmd)) { line(c.r(`✕ ${s.resume.cmd} isn't on your PATH · install ${s.agentName} first`)); return 1; }
|
|
120
|
+
line(c.m(` ${shortPath(s.cwd)} $ ${s.resume.cmd} ${s.resume.args.join(' ')}`));
|
|
121
|
+
line(c.m(` gitroots continues when ${s.agentName} exits`));
|
|
122
|
+
line();
|
|
123
|
+
const code = await new Promise((resolve) => {
|
|
124
|
+
const child = spawn(s.resume.cmd, s.resume.args, { cwd: s.cwd, stdio: 'inherit' });
|
|
125
|
+
child.on('error', (e) => { line(c.r(`✕ ${e.message}`)); resolve(1); });
|
|
126
|
+
child.on('exit', (n) => resolve(n ?? 0));
|
|
127
|
+
});
|
|
128
|
+
line();
|
|
129
|
+
line(c.m(` back in gitroots · ${s.agentName} exited${code ? ` with ${code}` : ''}`));
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function copyResume(s) {
|
|
134
|
+
const cmd = `cd ${JSON.stringify(s.cwd)} && ${s.resume.cmd} ${s.resume.args.join(' ')}`;
|
|
135
|
+
const { copyToClipboard } = await import('../ui/term.js');
|
|
136
|
+
const ok = await copyToClipboard(cmd);
|
|
137
|
+
line(ok ? `${c.g('✓')} Copied ${c.m(cmd)}` : ` ${cmd}`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- /machines
|
|
142
|
+
|
|
143
|
+
export async function machines(ctx) {
|
|
144
|
+
const d = await load(ctx, 'Loading machines…', () => ctx.api.agents({ signal: ctx.signal }));
|
|
145
|
+
if (ctx.json) { json(d.machines); return 0; }
|
|
146
|
+
if (!d.machines.length) { empty('no machines yet · add them in the gitroots app (Agents › Machines)'); return 0; }
|
|
147
|
+
for (const m of d.machines) {
|
|
148
|
+
const load_ = m.online ? c.m(`cpu ${m.cpu ?? '–'}% · mem ${m.mem ?? '–'}% · disk ${m.disk ?? '–'}%`) : c.m(`last seen ${ago(m.last_seen_at) || 'never'}`);
|
|
149
|
+
line(` ${m.online ? c.g('● online ') : c.m('○ offline')} ${c.b(pad(m.name, 22))}${pad(m.os || m.kind || '', 26)}${pad(m.agents.join(', '), 22)}${load_}`);
|
|
150
|
+
}
|
|
151
|
+
line();
|
|
152
|
+
next(ctx, 'watch');
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------- full screen: /watch and /attach
|
|
157
|
+
|
|
158
|
+
function logLines(r, detail) {
|
|
159
|
+
if (r.source === 'local') {
|
|
160
|
+
return (r.raw.log || []).map((l) => (l.who === 'you' ? `${c.i(c.b(pad('you', 7)))}${l.text}` : l.who === 'tool' ? `${c.m(pad('tool', 7))}${c.s(l.text)}` : `${c.g(c.b(pad('agent', 7)))}${l.text}`));
|
|
161
|
+
}
|
|
162
|
+
const log = detail?.log || r.raw.log || r.raw.last_log || [];
|
|
163
|
+
return log.map((l) => `${c.s(l)}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function keyChip(k, label) { return `${c.bg('key')(c.b(` ${k} `))} ${c.s(label)}`; }
|
|
167
|
+
|
|
168
|
+
export async function watch(ctx) {
|
|
169
|
+
if (!interactive()) return agents(ctx);
|
|
170
|
+
let rows = [];
|
|
171
|
+
let idx = 0;
|
|
172
|
+
let tick = 0;
|
|
173
|
+
let flash = null;
|
|
174
|
+
let busy = false;
|
|
175
|
+
let lastRemote = 0;
|
|
176
|
+
let remoteError = null;
|
|
177
|
+
let remote = null;
|
|
178
|
+
const details = {};
|
|
179
|
+
let lastDetail = 0;
|
|
180
|
+
const loadDetail = async () => {
|
|
181
|
+
const r = rows[idx];
|
|
182
|
+
if (!r || r.source !== 'remote' || Date.now() - lastDetail < 2500) return;
|
|
183
|
+
lastDetail = Date.now();
|
|
184
|
+
try { details[r.raw.id] = await ctx.api.agentSession(r.raw.id, { timeout: 8000 }); } catch { /* keep last */ }
|
|
185
|
+
};
|
|
186
|
+
const refresh = async (force) => {
|
|
187
|
+
if (busy) return;
|
|
188
|
+
busy = true;
|
|
189
|
+
try {
|
|
190
|
+
if (force || Date.now() - lastRemote > 3000) {
|
|
191
|
+
if (ctx.api.signedIn && ctx.api.orgId != null) {
|
|
192
|
+
try { remote = await ctx.api.agents({ timeout: 8000 }); remoteError = null; } catch (e) { remoteError = e.message; }
|
|
193
|
+
}
|
|
194
|
+
lastRemote = Date.now();
|
|
195
|
+
}
|
|
196
|
+
const keep = rows[idx]?.key;
|
|
197
|
+
const local = localSessions({ limit: 40 });
|
|
198
|
+
rows = [...(remote?.sessions || []).filter((s) => s.active).map(remoteRow), ...local.filter((s) => Date.now() - s.mtime < 3 * 864e5).map(localRow)];
|
|
199
|
+
rows.sort((a, b) => (b.needs - a.needs) || (rank(a.status) - rank(b.status)));
|
|
200
|
+
const k = rows.findIndex((r) => r.key === keep);
|
|
201
|
+
idx = k >= 0 ? k : Math.min(idx, Math.max(0, rows.length - 1));
|
|
202
|
+
await loadDetail();
|
|
203
|
+
} finally { busy = false; }
|
|
204
|
+
};
|
|
205
|
+
await refresh(true);
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
let mode = null; // { type: 'send', text } | { type: 'confirm-stop' }
|
|
208
|
+
const draw = () => {
|
|
209
|
+
const { cols: W, rows: H } = size();
|
|
210
|
+
const spin = FRAMES[tick % FRAMES.length];
|
|
211
|
+
const n = (st) => rows.filter((r) => r.status === st).length;
|
|
212
|
+
const left = ` ${c.g(c.b('gitroots'))} ${c.b('watch')} ${c.s(`${n('live')} live · ${rows.filter((r) => r.needs).length} need you · ${n('idle')} idle`)}${remoteError ? ` ${c.w(`◐ gitroots: ${remoteError}`)}` : ''}`;
|
|
213
|
+
const right = `${c.m(`${new Date().toTimeString().slice(0, 8)} · live`)} `;
|
|
214
|
+
const frame = [c.bg('bar')(fit(left, W - width(right)) + right), ''];
|
|
215
|
+
frame.push(` ${h(`${pad('STATE', 14)}${pad('AGENT', 13)}${pad('SESSION', 38)}WHERE`)}`);
|
|
216
|
+
const listH = Math.max(3, Math.min(rows.length, Math.floor((H - 6) / 2)));
|
|
217
|
+
const start = Math.max(0, Math.min(idx - listH + 1, rows.length - listH));
|
|
218
|
+
for (let i = start; i < start + listH && i < rows.length; i++) frame.push(i === idx ? c.bg('sel')(fit(rowText(rows[i], true, W), W)) : rowText(rows[i], false, W));
|
|
219
|
+
if (!rows.length) frame.push(c.m(' no agent sessions · start Claude Code or Codex, or add machines in the gitroots app'));
|
|
220
|
+
frame.push('');
|
|
221
|
+
const r = rows[idx];
|
|
222
|
+
const boxH = H - frame.length - 3;
|
|
223
|
+
if (r && boxH > 2) {
|
|
224
|
+
frame.push(` ${c.b(r.title)} ${c.m(`· ${r.agentName} · ${r.where}${r.branch ? ` · ${r.branch}` : ''}`)}`);
|
|
225
|
+
let body = logLines(r, r.source === 'remote' ? details[r.raw.id] : null);
|
|
226
|
+
if (r.source === 'remote' && r.raw.ask?.what) body = [...body, '', `${c.w(c.b('◐ asks'))} ${r.raw.ask.what}${r.raw.ask.cmd ? c.m(` · ${r.raw.ask.cmd}`) : ''}`, r.raw.can_decide ? c.g('y approve · n deny') : c.m('waiting for the session owner')];
|
|
227
|
+
if (r.status === 'live') body.push(`${c.i(spin)} ${c.s('working')}`);
|
|
228
|
+
const inner = boxH - 2;
|
|
229
|
+
const view = body.slice(-inner);
|
|
230
|
+
while (view.length < inner) view.unshift('');
|
|
231
|
+
frame.push(` ${c.m(`╭${'─'.repeat(W - 4)}╮`)}`);
|
|
232
|
+
for (const l of view) frame.push(` ${c.m('│')} ${fit(l, W - 6)} ${c.m('│')}`);
|
|
233
|
+
frame.push(` ${c.m(`╰${'─'.repeat(W - 4)}╯`)}`);
|
|
234
|
+
}
|
|
235
|
+
while (frame.length < H - 1) frame.push('');
|
|
236
|
+
let bar;
|
|
237
|
+
if (mode?.type === 'send') bar = ` ${c.s('message:')} ${mode.text}${c.inv(' ')} ${c.m('enter send · esc cancel')}`;
|
|
238
|
+
else if (mode?.type === 'stop') bar = ` ${c.w(`Stop "${fit(rows[idx]?.title || '', 40)}"?`)} ${c.m('(y/N)')}`;
|
|
239
|
+
else {
|
|
240
|
+
const cur = rows[idx];
|
|
241
|
+
const keys = [keyChip('↑↓', 'move'), keyChip('enter', 'open')];
|
|
242
|
+
if (cur?.source === 'remote') {
|
|
243
|
+
if (cur.raw.can_decide) keys.push(keyChip('y', 'approve'), keyChip('n', 'deny'));
|
|
244
|
+
if (cur.raw.can_send) keys.push(keyChip('m', 'message'));
|
|
245
|
+
if (cur.raw.can_stop) keys.push(keyChip('s', 'stop'));
|
|
246
|
+
} else if (cur) keys.push(keyChip('r', 'resume here'));
|
|
247
|
+
keys.push(keyChip('q', 'back'));
|
|
248
|
+
bar = ` ${keys.join(' ')}`;
|
|
249
|
+
}
|
|
250
|
+
if (flash && Date.now() < flash.until) bar = `${fit(bar, Math.max(0, W - width(flash.msg) - 4))} ${flash.msg} `;
|
|
251
|
+
frame.push(c.bg('bar')(fit(bar, W)));
|
|
252
|
+
paintFrame(frame.slice(0, H));
|
|
253
|
+
};
|
|
254
|
+
const say = (msg) => { flash = { msg, until: Date.now() + 4000 }; };
|
|
255
|
+
const act = async (r, action, text) => {
|
|
256
|
+
try {
|
|
257
|
+
const res = await ctx.api.agentAction(r.raw.id, action, text);
|
|
258
|
+
say(c.g(`✓ ${{ approve: 'approved', deny: 'denied', send: 'sent', stop: 'stop requested' }[action]} · ${res.note || 'queued'}`));
|
|
259
|
+
} catch (e) { say(c.r(`✕ ${e.message}`)); }
|
|
260
|
+
refresh(true).then(draw);
|
|
261
|
+
};
|
|
262
|
+
const finish = (after) => {
|
|
263
|
+
clearInterval(timer);
|
|
264
|
+
process.stdin.off('data', onData);
|
|
265
|
+
process.stdout.off('resize', draw);
|
|
266
|
+
process.stdin.setRawMode(false);
|
|
267
|
+
process.stdin.pause();
|
|
268
|
+
leaveFullscreen();
|
|
269
|
+
resolve(after ? after() : 0);
|
|
270
|
+
return true;
|
|
271
|
+
};
|
|
272
|
+
const onKey = (k) => {
|
|
273
|
+
const r = rows[idx];
|
|
274
|
+
if (mode?.type === 'send') {
|
|
275
|
+
if (k.name === 'escape') mode = null;
|
|
276
|
+
else if (k.name === 'enter') { const t = mode.text.trim(); mode = null; if (t && r) act(r, 'send', t); }
|
|
277
|
+
else if (k.name === 'backspace') mode.text = mode.text.slice(0, -1);
|
|
278
|
+
else if (k.ch) mode.text += k.ch;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (mode?.type === 'stop') { mode = null; if ((k.ch === 'y' || k.ch === 'Y') && r) act(r, 'stop'); return; }
|
|
282
|
+
switch (k.ch || k.name) {
|
|
283
|
+
case 'q': case 'escape': case 'ctrl-c': return finish();
|
|
284
|
+
case 'up': case 'k': if (rows.length) { idx = (idx - 1 + rows.length) % rows.length; lastDetail = 0; loadDetail().then(draw); } break;
|
|
285
|
+
case 'down': case 'j': if (rows.length) { idx = (idx + 1) % rows.length; lastDetail = 0; loadDetail().then(draw); } break;
|
|
286
|
+
case 'enter': case 'a': if (r) return finish(() => attachView(ctx, r).then(() => watch(ctx))); break;
|
|
287
|
+
case 'y': if (r?.source === 'remote' && r.raw.can_decide) act(r, 'approve'); break;
|
|
288
|
+
case 'n': if (r?.source === 'remote' && r.raw.can_decide) act(r, 'deny'); break;
|
|
289
|
+
case 'm': if (r?.source === 'remote' && r.raw.can_send) mode = { type: 'send', text: '' }; break;
|
|
290
|
+
case 's': if (r?.source === 'remote' && r.raw.can_stop) mode = { type: 'stop' }; break;
|
|
291
|
+
case 'r': if (r?.source === 'local') return finish(() => resumeLocal(r.raw)); break;
|
|
292
|
+
default: break;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const onData = (buf) => { for (const k of parseKeys(buf)) { const out = onKey(k); if (out !== undefined) return; } draw(); };
|
|
296
|
+
enterFullscreen();
|
|
297
|
+
process.stdin.setRawMode(true);
|
|
298
|
+
process.stdin.resume();
|
|
299
|
+
process.stdin.on('data', onData);
|
|
300
|
+
process.stdout.on('resize', draw);
|
|
301
|
+
const timer = setInterval(() => { tick++; if (tick % 15 === 0) refresh().then(draw); else draw(); }, 100);
|
|
302
|
+
draw();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function attach(ctx, args) {
|
|
307
|
+
const d = await load(ctx, 'Looking for the session…', () => gather(ctx, { quiet: true }));
|
|
308
|
+
let r = null;
|
|
309
|
+
const id = args[0];
|
|
310
|
+
if (id) r = d.rows.find((x) => String(x.id) === id || String(x.id).startsWith(id) || x.key === id);
|
|
311
|
+
if (!r) {
|
|
312
|
+
if (!interactive() || !d.rows.length) throw new Error(d.rows.length ? `no session ${id}` : 'no agent sessions to attach to');
|
|
313
|
+
line(`${c.i(c.b('?'))} Which session?`);
|
|
314
|
+
const res = await select({ count: d.rows.length, render: (i) => windowed(d.rows, i, 12, (x, sel) => rowText(x, sel)) });
|
|
315
|
+
if (res.action === 'cancel') return 0;
|
|
316
|
+
r = d.rows[res.index];
|
|
317
|
+
}
|
|
318
|
+
return attachView(ctx, r);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function attachView(ctx, r) {
|
|
322
|
+
if (!interactive()) { for (const l of logLines(r)) line(l); return Promise.resolve(0); }
|
|
323
|
+
return new Promise((resolve) => {
|
|
324
|
+
let detail = null;
|
|
325
|
+
let tick = 0;
|
|
326
|
+
let flash = null;
|
|
327
|
+
let mode = null;
|
|
328
|
+
let row = r;
|
|
329
|
+
const refresh = async () => {
|
|
330
|
+
try {
|
|
331
|
+
if (row.source === 'remote') { detail = await ctx.api.agentSession(row.raw.id, { timeout: 8000 }); row = { ...row, raw: { ...row.raw, ...detail }, status: detail.status }; }
|
|
332
|
+
else { const s = refreshLocal(row.raw); row = { ...row, raw: s, status: s.status, updated: agoMs(s.mtime) }; }
|
|
333
|
+
} catch (e) { flash = { msg: c.w(`◐ ${e.message}`), until: Date.now() + 4000 }; }
|
|
334
|
+
};
|
|
335
|
+
const draw = () => {
|
|
336
|
+
const { cols: W, rows: H } = size();
|
|
337
|
+
const s = row.raw;
|
|
338
|
+
const head = ` ${c.g(c.b('gitroots'))} ${c.b(`attached · ${fit(row.title, 40)}`)} ${c.s(`${row.agentName} · ${row.where}`)} ${statusMark(row.status)}`;
|
|
339
|
+
const frame = [c.bg('attach')(fit(head, W)), ''];
|
|
340
|
+
let body = logLines(row, detail);
|
|
341
|
+
if (row.source === 'remote' && s.ask?.what && row.status === 'waiting') {
|
|
342
|
+
body = [...body, '', `${c.w(c.b(' ◐ Approval needed'))} ${s.ask.what}`, s.ask.cmd ? ` ${c.s(s.ask.cmd)}` : '', s.ask.why ? ` ${c.m(s.ask.why)}` : '', s.can_decide ? ` ${c.g('y approve')} ${c.r('n deny')}` : c.m(' waiting for the session owner')].filter((x) => x !== '');
|
|
343
|
+
}
|
|
344
|
+
if (detail?.pending?.length) body.push('', c.m(` ${detail.pending.length} request${detail.pending.length > 1 ? 's' : ''} waiting for the gitroots app on ${row.where}: ${detail.pending.map((p) => p.kind).join(', ')}`));
|
|
345
|
+
if (row.status === 'live') body.push(`${c.i(FRAMES[tick % FRAMES.length])} ${c.s('working')}`);
|
|
346
|
+
const bodyH = H - 4;
|
|
347
|
+
const view = body.slice(-bodyH);
|
|
348
|
+
while (view.length < bodyH) view.push('');
|
|
349
|
+
for (const l of view) frame.push(` ${fit(l, W - 2)}`);
|
|
350
|
+
let bar;
|
|
351
|
+
if (mode?.type === 'send') bar = ` ${c.g('❯')} ${mode.text}${c.inv(' ')} ${c.m('enter send · esc cancel')}`;
|
|
352
|
+
else if (mode?.type === 'stop') bar = ` ${c.w('Stop this session?')} ${c.m('(y/N)')}`;
|
|
353
|
+
else {
|
|
354
|
+
const keys = [];
|
|
355
|
+
if (row.source === 'remote') {
|
|
356
|
+
if (s.can_decide && row.status === 'waiting') keys.push(keyChip('y', 'approve'), keyChip('n', 'deny'));
|
|
357
|
+
if (s.can_send) keys.push(keyChip('m', 'message'));
|
|
358
|
+
if (s.can_stop) keys.push(keyChip('s', 'stop'));
|
|
359
|
+
} else keys.push(keyChip('r', `resume in ${s.resume.cmd}`));
|
|
360
|
+
keys.push(keyChip('q', 'back'));
|
|
361
|
+
bar = ` ${keys.join(' ')}`;
|
|
362
|
+
}
|
|
363
|
+
if (flash && Date.now() < flash.until) bar = `${fit(bar, Math.max(0, W - width(flash.msg) - 4))} ${flash.msg} `;
|
|
364
|
+
frame.push(c.bg('bar')(fit(bar, W)));
|
|
365
|
+
paintFrame(frame.slice(0, H));
|
|
366
|
+
};
|
|
367
|
+
const act = async (action, text) => {
|
|
368
|
+
try {
|
|
369
|
+
const res = await ctx.api.agentAction(row.raw.id, action, text);
|
|
370
|
+
flash = { msg: c.g(`✓ ${{ approve: 'approved', deny: 'denied', send: 'sent', stop: 'stop requested' }[action]} · ${res.note || 'queued'}`), until: Date.now() + 5000 };
|
|
371
|
+
} catch (e) { flash = { msg: c.r(`✕ ${e.message}`), until: Date.now() + 5000 }; }
|
|
372
|
+
await refresh();
|
|
373
|
+
draw();
|
|
374
|
+
};
|
|
375
|
+
const finish = (after) => {
|
|
376
|
+
clearInterval(timer);
|
|
377
|
+
process.stdin.off('data', onData);
|
|
378
|
+
process.stdout.off('resize', draw);
|
|
379
|
+
process.stdin.setRawMode(false);
|
|
380
|
+
process.stdin.pause();
|
|
381
|
+
leaveFullscreen();
|
|
382
|
+
resolve(after ? after() : 0);
|
|
383
|
+
return true;
|
|
384
|
+
};
|
|
385
|
+
const onKey = (k) => {
|
|
386
|
+
const s = row.raw;
|
|
387
|
+
if (mode?.type === 'send') {
|
|
388
|
+
if (k.name === 'escape') mode = null;
|
|
389
|
+
else if (k.name === 'enter') { const t = mode.text.trim(); mode = null; if (t) act('send', t); }
|
|
390
|
+
else if (k.name === 'backspace') mode.text = mode.text.slice(0, -1);
|
|
391
|
+
else if (k.ch) mode.text += k.ch;
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
if (mode?.type === 'stop') { mode = null; if (k.ch === 'y' || k.ch === 'Y') act('stop'); return undefined; }
|
|
395
|
+
switch (k.ch || k.name) {
|
|
396
|
+
case 'q': case 'escape': case 'ctrl-c': return finish();
|
|
397
|
+
case 'y': if (row.source === 'remote' && s.can_decide) act('approve'); break;
|
|
398
|
+
case 'n': if (row.source === 'remote' && s.can_decide) act('deny'); break;
|
|
399
|
+
case 'm': if (row.source === 'remote' && s.can_send) mode = { type: 'send', text: '' }; break;
|
|
400
|
+
case 's': if (row.source === 'remote' && s.can_stop) mode = { type: 'stop' }; break;
|
|
401
|
+
case 'r': if (row.source === 'local') return finish(() => resumeLocal(row.raw)); break;
|
|
402
|
+
default: break;
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
};
|
|
406
|
+
const onData = (buf) => { for (const k of parseKeys(buf)) { const out = onKey(k); if (out !== undefined) return; } draw(); };
|
|
407
|
+
enterFullscreen();
|
|
408
|
+
process.stdin.setRawMode(true);
|
|
409
|
+
process.stdin.resume();
|
|
410
|
+
process.stdin.on('data', onData);
|
|
411
|
+
process.stdout.on('resize', draw);
|
|
412
|
+
const timer = setInterval(() => { tick++; if (tick % 20 === 0) refresh().then(draw); else draw(); }, 100);
|
|
413
|
+
refresh().then(draw);
|
|
414
|
+
draw();
|
|
415
|
+
});
|
|
416
|
+
}
|