@oddessentials/agent-guild 0.6.0 → 0.7.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/web/app.js CHANGED
@@ -8,6 +8,8 @@ const THEME_KEY = 'agentGuild.theme';
8
8
  const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
9
9
  const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
10
10
  const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
11
+ const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
12
+ const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
11
13
  const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
12
14
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
13
15
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
@@ -23,6 +25,7 @@ const state = {
23
25
  statsFor: new Map(),
24
26
  news: null,
25
27
  changelog: null,
28
+ github: null,
26
29
  sessions: new Map(),
27
30
  views: new Map(),
28
31
  activeId: null,
@@ -55,9 +58,20 @@ function save(key, value) { try { value === null ? localStorage.removeItem(key)
55
58
  // ---- helpers --------------------------------------------------------------
56
59
 
57
60
  let toastTimer;
58
- function toast(message, ms = 5000) {
61
+ function toast(message, ms = 5000, action = null) {
59
62
  const el = $('toast');
60
- el.textContent = message;
63
+ el.replaceChildren(message);
64
+ if (action) {
65
+ const button = document.createElement('button');
66
+ button.type = 'button';
67
+ button.className = 'btn toast-action';
68
+ button.textContent = action.label;
69
+ button.addEventListener('click', () => {
70
+ el.hidden = true;
71
+ action.run();
72
+ });
73
+ el.append(button);
74
+ }
61
75
  el.hidden = false;
62
76
  clearTimeout(toastTimer);
63
77
  toastTimer = setTimeout(() => { el.hidden = true; }, ms);
@@ -1611,8 +1625,12 @@ function shortId(id) {
1611
1625
  return id.length > 12 ? id.slice(0, 8) : id;
1612
1626
  }
1613
1627
 
1628
+ function copyText(text, what) {
1629
+ navigator.clipboard?.writeText(text).then(() => toast(`Copied ${what}`, 2500), () => toast(text, 10000));
1630
+ }
1631
+
1614
1632
  function copyId(id) {
1615
- navigator.clipboard?.writeText(id).then(() => toast(`Copied ${id}`, 2500), () => toast(id, 8000));
1633
+ copyText(id, id);
1616
1634
  }
1617
1635
 
1618
1636
  function paintIdButton(button, id) {
@@ -1782,6 +1800,598 @@ function resumeById(event) {
1782
1800
  if (provider && id) resumeFromHistory(provider, id, null);
1783
1801
  }
1784
1802
 
1803
+ // ---- GitHub ---------------------------------------------------------------
1804
+
1805
+ const GITHUB_SCOPES = {
1806
+ repo: 'Read and write access to all your repositories, private ones included. GitHub offers apps no read-only choice; Agent Guild only lists them.',
1807
+ 'write:public_key': 'Add the SSH key Agent Guild creates for this account.',
1808
+ };
1809
+ const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, opener: null, card: null, started: new Set(), announced: new Set() };
1810
+ let githubLoading = null;
1811
+
1812
+ function el(tag, className, ...children) {
1813
+ const node = document.createElement(tag);
1814
+ if (className) node.className = className;
1815
+ node.append(...children.filter((child) => child !== null && child !== undefined && child !== false));
1816
+ return node;
1817
+ }
1818
+
1819
+ function button(label, onClick, className = 'btn') {
1820
+ const node = el('button', className, label);
1821
+ node.type = 'button';
1822
+ node.addEventListener('click', onClick);
1823
+ return node;
1824
+ }
1825
+
1826
+ function externalLink(label, href, className = 'console-link') {
1827
+ const link = el('a', className, label);
1828
+ link.href = webHref(href) ?? '';
1829
+ link.target = '_blank';
1830
+ link.rel = 'noopener noreferrer';
1831
+ link.setAttribute('aria-description', 'Opens in a new tab');
1832
+ return link;
1833
+ }
1834
+
1835
+ function githubAccount() {
1836
+ return state.github?.accounts.find((a) => a.id === githubView.accountId) ?? null;
1837
+ }
1838
+
1839
+ function selectGitHubAccount(id) {
1840
+ if (githubView.accountId === id) return;
1841
+ githubView.accountId = id;
1842
+ githubView.repos = null;
1843
+ githubView.reposFor = null;
1844
+ githubView.error = null;
1845
+ save(GITHUB_ACCOUNT_KEY, id === null ? null : String(id));
1846
+ }
1847
+
1848
+ let githubAgain = false;
1849
+
1850
+ function loadGitHub() {
1851
+ if (githubLoading) {
1852
+ githubAgain = true;
1853
+ return githubLoading;
1854
+ }
1855
+ githubLoading = api('GET', '/github').then(({ github }) => setGitHub(github), (err) => {
1856
+ if (err instanceof AuthError) return showAuth(err.message);
1857
+ toast(err.message, 8000);
1858
+ }).finally(() => {
1859
+ githubLoading = null;
1860
+ if (githubAgain) {
1861
+ githubAgain = false;
1862
+ loadGitHub();
1863
+ }
1864
+ });
1865
+ return githubLoading;
1866
+ }
1867
+
1868
+ function setGitHub(github) {
1869
+ const before = state.github?.signIn;
1870
+ state.github = github;
1871
+ const done = github.signIn?.status === 'done' && before?.status === 'pending' ? github.signIn : null;
1872
+ if (done) {
1873
+ selectGitHubAccount(done.accountId);
1874
+ Object.assign(githubView, { repos: null, reposFor: null, error: null });
1875
+ }
1876
+ if (!githubAccount()) selectGitHubAccount(github.accounts[0]?.id ?? null);
1877
+ const account = githubAccount();
1878
+ if (done && account) {
1879
+ toast(done.again
1880
+ ? `You are already signed in as @${account.login}; that sign-in was renewed. To add another account, switch to it on github.com first.`
1881
+ : `Signed in to GitHub as @${account.login}.`, done.again ? 10000 : 4000);
1882
+ }
1883
+ if (!$('github').open) return;
1884
+ renderGitHub();
1885
+ ensureRepos();
1886
+ }
1887
+
1888
+ function cloneParent() {
1889
+ return $('github-parent').value.trim();
1890
+ }
1891
+
1892
+ function reposKey(account) {
1893
+ return account ? `${account.id}\n${cloneParent()}` : null;
1894
+ }
1895
+
1896
+ function ensureRepos() {
1897
+ const account = githubAccount();
1898
+ if (!account || account.needsSignIn) return;
1899
+ const key = reposKey(account);
1900
+ if (githubView.reposFor !== key && githubView.loading !== key) loadRepos();
1901
+ }
1902
+
1903
+ async function loadRepos({ refresh = false } = {}) {
1904
+ const account = githubAccount();
1905
+ if (!account) return;
1906
+ const key = reposKey(account);
1907
+ const parent = cloneParent();
1908
+ githubView.loading = key;
1909
+ renderGitHub();
1910
+ const ask = (withParent) => {
1911
+ const query = new URLSearchParams();
1912
+ if (withParent && parent) query.set('parent', parent);
1913
+ if (refresh) query.set('refresh', '1');
1914
+ return api('GET', `/github/accounts/${account.id}/repos?${query}`);
1915
+ };
1916
+ let result = null;
1917
+ let error = null;
1918
+ let parentError = null;
1919
+ try {
1920
+ try {
1921
+ result = (await ask(true)).repos;
1922
+ } catch (err) {
1923
+ if (err.code !== 'bad_cwd') throw err;
1924
+ parentError = err.message;
1925
+ result = (await ask(false)).repos;
1926
+ }
1927
+ } catch (err) {
1928
+ if (err instanceof AuthError) return showAuth(err.message);
1929
+ error = err.message;
1930
+ }
1931
+ if (githubView.loading !== key) return;
1932
+ Object.assign(githubView, { loading: null, reposFor: key, error, parentError });
1933
+ if (result || !githubView.repos || githubView.repos.accountId !== account.id) githubView.repos = result;
1934
+ renderGitHub();
1935
+ }
1936
+
1937
+ async function startGitHubSignIn() {
1938
+ try {
1939
+ setGitHub((await api('POST', '/github/sign-in')).github);
1940
+ } catch (err) {
1941
+ if (err instanceof AuthError) return showAuth(err.message);
1942
+ toast(err.message, 8000);
1943
+ }
1944
+ }
1945
+
1946
+ async function cancelGitHubSignIn() {
1947
+ try {
1948
+ setGitHub((await api('DELETE', '/github/sign-in')).github);
1949
+ } catch (err) {
1950
+ toast(err.message);
1951
+ }
1952
+ }
1953
+
1954
+ async function setupGitHubSsh(account) {
1955
+ try {
1956
+ const result = await api('POST', `/github/accounts/${account.id}/ssh`);
1957
+ if (result.account.ssh.status === 'ready') toast(`SSH is ready for @${account.login}.`, 4000);
1958
+ } catch (err) {
1959
+ if (err instanceof AuthError) return showAuth(err.message);
1960
+ toast(err.message, 8000);
1961
+ }
1962
+ loadGitHub();
1963
+ }
1964
+
1965
+ async function signOutGitHub(account) {
1966
+ const text = `Sign out of @${account.login}? Agent Guild forgets this sign-in. Its SSH key stays in the Agent Guild data folder and on your GitHub account, so existing clones keep working.\n\nTo remove Agent Guild's access entirely, revoke it on GitHub; GitHub then also removes the SSH keys Agent Guild added.`;
1967
+ if (!confirm(text)) return;
1968
+ try {
1969
+ const { github } = await api('DELETE', `/github/accounts/${account.id}`);
1970
+ if (githubView.accountId === account.id) selectGitHubAccount(null);
1971
+ setGitHub(github);
1972
+ } catch (err) {
1973
+ toast(err.message);
1974
+ }
1975
+ }
1976
+
1977
+ function openGitHub() {
1978
+ const dialog = $('github');
1979
+ $('github-parent').value = load(CLONE_PARENT_KEY) ?? $('cwd').value.trim();
1980
+ $('github-filter').value = '';
1981
+ githubView.card = null;
1982
+ if (!dialog.open) {
1983
+ githubView.opener = document.activeElement;
1984
+ dialog.showModal();
1985
+ }
1986
+ renderGitHub();
1987
+ if (githubAccount() && !githubAccount().needsSignIn) loadRepos();
1988
+ loadGitHub();
1989
+ ($('github-card').querySelector('.btn.primary') ?? $('github-close')).focus();
1990
+ }
1991
+
1992
+ function closeGitHub({ focusOpener = true } = {}) {
1993
+ if (!$('github').open) return;
1994
+ if (!focusOpener) githubView.opener = false;
1995
+ $('github').close();
1996
+ }
1997
+
1998
+ function renderGitHub() {
1999
+ const github = state.github;
2000
+ const account = githubAccount();
2001
+ const repos = githubView.repos?.accountId === account?.id ? githubView.repos : null;
2002
+ const sub = !github ? 'Loading…'
2003
+ : !account ? 'Clone your repositories over SSH'
2004
+ : [`@${account.login}`, account.name, repos && `${repos.repos.length} ${repos.repos.length === 1 ? 'repository' : 'repositories'}`].filter(Boolean).join(' · ');
2005
+ $('github-sub').textContent = sub;
2006
+ renderGitHubChips(github);
2007
+ renderGitHubCard(github, account);
2008
+ renderGitHubStatus(github, account);
2009
+ renderGitHubRepos(github, account, repos);
2010
+ }
2011
+
2012
+ function githubAvatar(account) {
2013
+ if (account.avatar?.startsWith('data:image/')) {
2014
+ const img = el('img', 'github-avatar');
2015
+ img.src = account.avatar;
2016
+ img.alt = '';
2017
+ return img;
2018
+ }
2019
+ const mono = el('span', 'github-avatar', account.login.charAt(0).toUpperCase());
2020
+ mono.style.setProperty('--c', `hsl(${hueFor(account.login)} 45% 45%)`);
2021
+ return mono;
2022
+ }
2023
+
2024
+ function renderGitHubChips(github) {
2025
+ const accounts = github?.accounts ?? [];
2026
+ $('github-accounts').hidden = accounts.length === 0;
2027
+ const host = $('github-chips');
2028
+ const focused = document.activeElement?.closest?.('#github-chips .account-chip')?.dataset.account;
2029
+ host.replaceChildren(...accounts.map((account) => {
2030
+ const chip = el('button', `account-chip github-chip${account.needsSignIn ? ' unsigned' : ''}`, githubAvatar(account), el('span', null, account.login));
2031
+ chip.type = 'button';
2032
+ chip.setAttribute('role', 'tab');
2033
+ chip.dataset.account = account.id;
2034
+ chip.setAttribute('aria-selected', String(account.id === githubView.accountId));
2035
+ chip.title = account.needsSignIn ? `@${account.login}: sign in again` : `${account.name ? `${account.name} · ` : ''}@${account.login}${account.ssh.status === 'ready' ? ' · SSH ready' : ' · SSH not set up'}`;
2036
+ chip.addEventListener('click', () => {
2037
+ selectGitHubAccount(account.id);
2038
+ githubView.card = null;
2039
+ renderGitHub();
2040
+ ensureRepos();
2041
+ });
2042
+ return chip;
2043
+ }));
2044
+ if (focused) [...host.children].find((chip) => chip.dataset.account === focused)?.focus({ preventScroll: true });
2045
+ const pending = github?.signIn?.status === 'pending';
2046
+ $('github-add').hidden = accounts.length === 0 || pending;
2047
+ }
2048
+
2049
+ function minutesLeft(iso) {
2050
+ return Math.max(1, Math.ceil((Date.parse(iso) - Date.now()) / 60000));
2051
+ }
2052
+
2053
+ function scopeList(github) {
2054
+ return el('ul', 'github-scopes', ...(github.scopes ?? []).map((scope) => el('li', null, el('code', null, scope), ` ${GITHUB_SCOPES[scope] ?? ''}`)));
2055
+ }
2056
+
2057
+ function signInCard(github) {
2058
+ return [
2059
+ el('h3', null, 'Sign in to GitHub'),
2060
+ el('p', null, 'See your repositories and clone them over SSH, with a key Agent Guild keeps for each account.'),
2061
+ el('p', 'github-small', 'GitHub will ask you to allow Agent Guild to:'),
2062
+ scopeList(github),
2063
+ el('p', 'github-small', 'Your sign-in stays in the Agent Guild data folder on this computer; this page never receives it.'),
2064
+ el('div', 'github-actions', button('Sign in with GitHub', startGitHubSignIn, 'btn primary')),
2065
+ ];
2066
+ }
2067
+
2068
+ function codeCard(signIn) {
2069
+ const code = signIn.userCode;
2070
+ const open = externalLink('Open GitHub', signIn.verificationUri, 'btn primary');
2071
+ open.addEventListener('click', () => navigator.clipboard?.writeText(code).catch(() => {}));
2072
+ open.title = `Copies the code and opens ${signIn.verificationUri}`;
2073
+ const codeText = el('span', 'github-code-text', code);
2074
+ codeText.setAttribute('aria-label', `Code ${[...code].join(' ')}`);
2075
+ return [
2076
+ el('h3', null, 'Enter this code on GitHub'),
2077
+ el('div', 'github-code', codeText, button('Copy', () => copyText(code, 'the code'))),
2078
+ el('div', 'github-actions', open, button('Cancel', cancelGitHubSignIn)),
2079
+ el('p', 'github-small github-waiting', `Waiting for you to approve Agent Guild on GitHub. The code expires in ${minutesLeft(signIn.expiresAt)} min.`),
2080
+ el('p', 'github-small', 'Adding another account? Switch to it on github.com before you enter the code.'),
2081
+ ];
2082
+ }
2083
+
2084
+ function signInResultCard(signIn) {
2085
+ const text = signIn.status === 'expired' ? 'The code expired before it was entered on GitHub.'
2086
+ : signIn.status === 'denied' ? 'The sign-in was declined on GitHub.'
2087
+ : `The sign-in did not finish: ${signIn.error || 'GitHub refused it'}.`;
2088
+ return [
2089
+ el('h3', null, 'Not signed in'),
2090
+ el('p', null, text),
2091
+ el('div', 'github-actions', button('Try again', startGitHubSignIn, 'btn primary'), button('Dismiss', cancelGitHubSignIn)),
2092
+ ];
2093
+ }
2094
+
2095
+ function signInAgainCard(account) {
2096
+ return [
2097
+ el('h3', null, `Sign in again as @${account.login}`),
2098
+ el('p', null, `GitHub no longer accepts Agent Guild's sign-in for @${account.login}. If github.com is signed in to another account, switch to @${account.login} there first.`),
2099
+ el('div', 'github-actions', button('Sign in again', startGitHubSignIn, 'btn primary'), button('Sign out', () => signOutGitHub(account))),
2100
+ ];
2101
+ }
2102
+
2103
+ function sshCard(github, account) {
2104
+ const ssh = account.ssh;
2105
+ const missing = !github.tools.ssh || !github.tools.sshKeygen;
2106
+ const step = (done, text) => el('li', done ? 'done' : null, text);
2107
+ const parts = [
2108
+ el('h3', null, `Set up SSH for @${account.login}`),
2109
+ el('p', null, 'Agent Guild clones over SSH with a key of its own for each account, so your other SSH keys and settings are never used or changed.'),
2110
+ el('ol', 'github-steps',
2111
+ step(Boolean(ssh.key), 'Create a key for this account in the Agent Guild data folder'),
2112
+ step(false, 'Add its public key to your GitHub account'),
2113
+ step(false, `Check that GitHub signs in as @${account.login}`)),
2114
+ ];
2115
+ if (missing) {
2116
+ parts.push(el('p', 'github-error', 'OpenSSH (ssh and ssh-keygen) was not found. On Windows, add the "OpenSSH Client" optional feature in Settings, or install Git for Windows; on Linux, install the openssh-client package. Then check again.'));
2117
+ } else if (ssh.error) {
2118
+ parts.push(el('p', 'github-error', ssh.error.message));
2119
+ }
2120
+ const setup = button(ssh.settingUp ? 'Setting up…' : ssh.error || ssh.key ? 'Check again' : 'Set up SSH', () => setupGitHubSsh(account), 'btn primary');
2121
+ setup.disabled = ssh.settingUp || missing;
2122
+ const actions = el('div', 'github-actions', setup);
2123
+ if (ssh.error?.manual && ssh.publicKey) {
2124
+ actions.append(button('Copy public key', () => copyText(ssh.publicKey, 'the public key')), externalLink('Open GitHub SSH settings', github.newKeyUrl, 'btn'));
2125
+ }
2126
+ parts.push(actions);
2127
+ const approve = externalLink('request access', github.appUrl);
2128
+ parts.push(el('p', 'github-small', 'Organizations that restrict third-party apps accept this key only once an owner approves Agent Guild: ', approve, '.'));
2129
+ return parts;
2130
+ }
2131
+
2132
+ function renderGitHubCard(github, account) {
2133
+ const card = $('github-card');
2134
+ let kind = null;
2135
+ let build = null;
2136
+ const signIn = github?.signIn;
2137
+ if (!github) kind = null;
2138
+ else if (signIn?.status === 'pending') [kind, build] = [`code:${signIn.userCode}:${minutesLeft(signIn.expiresAt)}`, () => codeCard(signIn)];
2139
+ else if (signIn && signIn.status !== 'done') [kind, build] = [`result:${signIn.status}`, () => signInResultCard(signIn)];
2140
+ else if (!account) [kind, build] = ['welcome', () => signInCard(github)];
2141
+ else if (account.needsSignIn) [kind, build] = [`again:${account.id}`, () => signInAgainCard(account)];
2142
+ else if (account.ssh.status !== 'ready') {
2143
+ const { settingUp, key, error } = account.ssh;
2144
+ [kind, build] = [`ssh:${account.id}:${settingUp}:${Boolean(key)}:${error?.message}:${github.tools.ssh && github.tools.sshKeygen}`, () => sshCard(github, account)];
2145
+ }
2146
+ card.hidden = !kind;
2147
+ if (kind === githubView.card) return;
2148
+ const hadFocus = card.contains(document.activeElement);
2149
+ githubView.card = kind;
2150
+ card.replaceChildren(...(build ? build() : []));
2151
+ if (hadFocus) (card.querySelector('.btn.primary:not(:disabled)') ?? card.querySelector('button, a'))?.focus({ preventScroll: true });
2152
+ }
2153
+
2154
+ function renderGitHubStatus(github, account) {
2155
+ const strip = $('github-status');
2156
+ strip.hidden = !account || account.needsSignIn;
2157
+ if (strip.hidden) return strip.replaceChildren();
2158
+ const ssh = account.ssh;
2159
+ const text = ssh.status === 'ready'
2160
+ ? el('span', 'github-ready', 'SSH ready', el('span', 'github-small', ` · checked ${relativeTime(ssh.verifiedAt)}`))
2161
+ : el('span', 'github-small', 'SSH not set up yet');
2162
+ const actions = el('span', 'github-status-actions');
2163
+ if (ssh.status === 'ready') {
2164
+ const check = button(ssh.settingUp ? 'Checking…' : 'Check SSH', () => setupGitHubSsh(account));
2165
+ check.disabled = ssh.settingUp;
2166
+ check.title = `Check that GitHub still signs in as @${account.login} with Agent Guild's key`;
2167
+ actions.append(check);
2168
+ }
2169
+ actions.append(button('Sign out', () => signOutGitHub(account)));
2170
+ const focused = strip.contains(document.activeElement) ? document.activeElement.textContent : null;
2171
+ strip.replaceChildren(text, actions);
2172
+ if (focused) [...strip.querySelectorAll('button')].find((b) => b.textContent === focused)?.focus({ preventScroll: true });
2173
+ }
2174
+
2175
+ function runningClone(target) {
2176
+ return [...state.sessions.values()].find((s) => s.task === 'clone' && s.status === 'running' && s.clone?.path === target) ?? null;
2177
+ }
2178
+
2179
+ function cloneBlocker(github, account) {
2180
+ if (!github.tools.git) return 'Git was not found. Install it from git-scm.com, then reopen this dialog.';
2181
+ if (account.ssh.status !== 'ready') return `Set up SSH for @${account.login} first.`;
2182
+ if (githubView.parentError) return githubView.parentError;
2183
+ if (!cloneParent()) return 'Choose the folder to clone into below.';
2184
+ return null;
2185
+ }
2186
+
2187
+ function buildRepoRow(fullName) {
2188
+ const node = $('github-repo-template').content.firstElementChild.cloneNode(true);
2189
+ node.dataset.repo = fullName;
2190
+ node.querySelector('.github-action').addEventListener('click', (event) => {
2191
+ const repo = githubView.repos?.repos.find((r) => r.fullName === fullName);
2192
+ if (repo) repoAction(repo, event.currentTarget);
2193
+ });
2194
+ return node;
2195
+ }
2196
+
2197
+ function repoAction(repo, control) {
2198
+ const running = runningClone(repo.target);
2199
+ if (running) {
2200
+ closeGitHub({ focusOpener: false });
2201
+ openPanel(running.id);
2202
+ } else if (repo.local === 'cloned') {
2203
+ useFolder(repo.target);
2204
+ } else {
2205
+ cloneRepo(repo, control);
2206
+ }
2207
+ }
2208
+
2209
+ function updateRepoRow(node, repo, blocker) {
2210
+ const name = node.querySelector('.repo-name');
2211
+ name.replaceChildren(el('span', 'repo-owner', `${repo.owner}/`), repo.name);
2212
+ name.title = repo.url;
2213
+ const title = node.querySelector('.history-title');
2214
+ title.replaceChildren(name,
2215
+ ...(repo.private ? [badge('', 'Private', 'Only people with access can see it')] : []),
2216
+ ...(repo.fork ? [badge('', 'Fork', 'A fork of another repository')] : []),
2217
+ ...(repo.archived ? [badge('', 'Archived', 'Read-only on GitHub')] : []));
2218
+ const running = runningClone(repo.target);
2219
+ const conflict = repo.local === 'conflict';
2220
+ const meta = node.querySelector('.history-meta');
2221
+ meta.textContent = conflict
2222
+ ? `${repo.target} already exists and is not a clone of ${repo.fullName}`
2223
+ : [running && 'Cloning…', repo.local === 'cloned' && `Cloned in ${repo.target}`, repo.description, repo.language, repo.pushedAt && `pushed ${relativeTime(repo.pushedAt)}`].filter(Boolean).join(' · ');
2224
+ meta.title = [repo.description, repo.target].filter(Boolean).join('\n');
2225
+ node.classList.toggle('conflict', conflict);
2226
+ node.classList.toggle('running', Boolean(running) || repo.local === 'cloned');
2227
+ const action = node.querySelector('.github-action');
2228
+ action.hidden = conflict && !running;
2229
+ action.className = `btn github-action${running ? '' : ' primary'}`;
2230
+ action.textContent = running ? 'Show' : repo.local === 'cloned' ? 'Use folder' : 'Clone';
2231
+ action.disabled = !running && repo.local !== 'cloned' && Boolean(blocker);
2232
+ action.title = running ? `Show the session cloning ${repo.fullName}`
2233
+ : repo.local === 'cloned' ? `Make ${repo.target} the working folder, so new sessions start there`
2234
+ : blocker || `Clone ${repo.fullName} into ${repo.target} over SSH`;
2235
+ action.setAttribute('aria-label', `${action.textContent} ${repo.fullName}`);
2236
+ }
2237
+
2238
+ function renderGitHubRepos(github, account, repos) {
2239
+ const ready = Boolean(github && account && !account.needsSignIn);
2240
+ $('github-tools').hidden = !ready;
2241
+ $('github-parent').closest('.github-form').hidden = !ready;
2242
+ const list = $('github-list');
2243
+ const filter = $('github-filter').value.trim().toLowerCase();
2244
+ const shown = ready && repos ? repos.repos.filter((repo) => !filter || `${repo.fullName}\n${repo.description ?? ''}`.toLowerCase().includes(filter)) : [];
2245
+ const blocker = ready ? cloneBlocker(github, account) : null;
2246
+ const rows = new Map([...list.children].map((node) => [node.dataset.repo, node]));
2247
+ const wanted = new Set(shown.map((repo) => repo.fullName));
2248
+ for (const [id, node] of rows) if (!wanted.has(id)) node.remove();
2249
+ shown.forEach((repo, index) => {
2250
+ const node = rows.get(repo.fullName) ?? buildRepoRow(repo.fullName);
2251
+ updateRepoRow(node, repo, blocker);
2252
+ if (list.children[index] !== node) list.insertBefore(node, list.children[index] || null);
2253
+ });
2254
+ const note = $('github-note');
2255
+ const lines = [];
2256
+ if (ready) {
2257
+ if (githubView.loading && !repos) lines.push('Loading repositories…');
2258
+ else if (githubView.error) lines.push(`Repositories could not be loaded: ${githubView.error}`);
2259
+ else if (repos && repos.repos.length === 0) lines.push(`@${account.login} has no repositories yet.`);
2260
+ else if (repos && shown.length === 0) lines.push('No repository matches the filter.');
2261
+ if (githubView.parentError) lines.push(githubView.parentError);
2262
+ if (repos?.truncated) lines.push(`Showing the ${repos.repos.length} most recently pushed repositories.`);
2263
+ if (repos) lines.push(['Missing an organization\'s repositories? Its owners may need to approve Agent Guild: ', externalLink('request access', github.appUrl), '.']);
2264
+ }
2265
+ note.replaceChildren(...lines.map((line) => el('span', null, ...[line].flat())));
2266
+ note.hidden = lines.length === 0;
2267
+ $('github-refresh').disabled = Boolean(githubView.loading);
2268
+ if (!ready) showCreate(false);
2269
+ renderCreate();
2270
+ }
2271
+
2272
+ async function startClone(account, fullName) {
2273
+ const { session } = await api('POST', '/github/clone', { account: account.id, repo: fullName, parent: cloneParent() });
2274
+ githubView.started.add(session.id);
2275
+ upsertSession(session);
2276
+ closeGitHub({ focusOpener: false });
2277
+ openPanel(session.id);
2278
+ }
2279
+
2280
+ async function cloneRepo(repo, control) {
2281
+ const account = githubAccount();
2282
+ if (!account) return;
2283
+ control.disabled = true;
2284
+ try {
2285
+ await startClone(account, repo.fullName);
2286
+ } catch (err) {
2287
+ if (err instanceof AuthError) return showAuth(err.message);
2288
+ toast(err.message, 8000);
2289
+ if (err.code === 'clone_exists' || err.code === 'folder_conflict') loadRepos();
2290
+ } finally {
2291
+ control.disabled = false;
2292
+ }
2293
+ }
2294
+
2295
+ function showCreate(open) {
2296
+ const form = $('github-create');
2297
+ form.hidden = !open;
2298
+ $('github-new').setAttribute('aria-expanded', String(open));
2299
+ if (!open) return;
2300
+ $('github-create-name').value = '';
2301
+ $('github-create-description').value = '';
2302
+ $('github-create-error').hidden = true;
2303
+ renderCreate();
2304
+ $('github-create-name').focus();
2305
+ }
2306
+
2307
+ function renderCreate() {
2308
+ const form = $('github-create');
2309
+ const account = githubAccount();
2310
+ if (form.hidden || !account) return;
2311
+ const repos = githubView.repos?.accountId === account.id ? githubView.repos : null;
2312
+ const owners = repos?.owners ?? [account.login];
2313
+ const select = $('github-create-owner');
2314
+ const chosen = select.value;
2315
+ if ([...select.options].map((o) => o.value).join('\n') !== owners.join('\n')) {
2316
+ select.replaceChildren(...owners.map((owner) => {
2317
+ const option = el('option', null, owner === account.login ? `${owner} (you)` : owner);
2318
+ option.value = owner;
2319
+ return option;
2320
+ }));
2321
+ select.value = owners.includes(chosen) ? chosen : owners[0];
2322
+ }
2323
+ const blocker = cloneBlocker(state.github, account);
2324
+ const clone = $('github-create-clone');
2325
+ clone.disabled = Boolean(blocker);
2326
+ if (blocker) clone.checked = false;
2327
+ const name = $('github-create-name').value.trim();
2328
+ $('github-create-clone-label').textContent = blocker ? 'Clone it' : `Clone it into ${cloneParent()}${name ? `/${name}` : ''}`;
2329
+ clone.parentElement.title = blocker ?? '';
2330
+ }
2331
+
2332
+ async function createRepo(event) {
2333
+ event.preventDefault();
2334
+ const account = githubAccount();
2335
+ if (!account) return;
2336
+ const submit = $('github-create-submit');
2337
+ const error = $('github-create-error');
2338
+ const owner = $('github-create-owner').value;
2339
+ const name = $('github-create-name').value.trim();
2340
+ const clone = $('github-create-clone').checked && !$('github-create-clone').disabled;
2341
+ submit.disabled = true;
2342
+ error.hidden = true;
2343
+ try {
2344
+ const { repo } = await api('POST', `/github/accounts/${account.id}/repos`, {
2345
+ owner,
2346
+ name,
2347
+ description: $('github-create-description').value.trim() || null,
2348
+ private: $('github-create-private').checked,
2349
+ readme: $('github-create-readme').checked,
2350
+ });
2351
+ showCreate(false);
2352
+ githubView.reposFor = null;
2353
+ if (clone) {
2354
+ toast(`Created ${repo.fullName} on GitHub.`, 4000);
2355
+ await startClone(account, repo.fullName);
2356
+ } else {
2357
+ toast(`Created ${repo.fullName} on GitHub.`, 6000);
2358
+ await loadRepos();
2359
+ }
2360
+ } catch (err) {
2361
+ if (err instanceof AuthError) return showAuth(err.message);
2362
+ if ($('github-create').hidden) {
2363
+ toast(err.message, 8000);
2364
+ loadRepos();
2365
+ } else {
2366
+ error.textContent = err.message;
2367
+ error.hidden = false;
2368
+ }
2369
+ } finally {
2370
+ submit.disabled = false;
2371
+ }
2372
+ }
2373
+
2374
+ function clonedPath(s) {
2375
+ return s.task === 'clone' && s.status === 'exited' && s.exitCode === 0 && s.clone?.path ? s.clone.path : null;
2376
+ }
2377
+
2378
+ function noticeClone(s) {
2379
+ if (s.task !== 'clone' || s.status !== 'exited' || githubView.announced.has(s.id)) return;
2380
+ githubView.announced.add(s.id);
2381
+ githubView.reposFor = null;
2382
+ if ($('github').open) loadRepos();
2383
+ if (!githubView.started.has(s.id)) return;
2384
+ if (clonedPath(s)) toast(`Cloned ${s.clone.repo} into ${s.clone.path}.`, 12000, { label: 'Use folder', run: () => useFolder(s.clone.path) });
2385
+ else toast(`Cloning ${s.clone?.repo ?? 'the repository'} did not finish. Its session shows why.`, 8000);
2386
+ }
2387
+
2388
+ function useFolder(dir) {
2389
+ $('cwd').value = dir;
2390
+ save(CWD_KEY, dir);
2391
+ if ($('github').open) renderGitHub();
2392
+ toast(`New sessions start in ${dir}.`, 4000);
2393
+ }
2394
+
1785
2395
  // ---- session cards --------------------------------------------------------
1786
2396
 
1787
2397
  const cards = new Map();
@@ -1824,6 +2434,10 @@ function buildCard(session) {
1824
2434
  node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
1825
2435
  node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
1826
2436
  node.querySelector('.resume').addEventListener('click', () => resumeCard(session.id));
2437
+ node.querySelector('.use-folder').addEventListener('click', () => {
2438
+ const path = state.sessions.get(session.id)?.clone?.path;
2439
+ if (path) useFolder(path);
2440
+ });
1827
2441
  node.querySelector('.session-id').addEventListener('click', () => {
1828
2442
  const id = toolSessionId(state.sessions.get(session.id) ?? session);
1829
2443
  if (id) copyId(id);
@@ -1889,6 +2503,9 @@ function updateCard(node, s) {
1889
2503
  node.classList.toggle('exited', s.status === 'exited');
1890
2504
  node.querySelector('.stop').hidden = s.status !== 'running';
1891
2505
  node.querySelector('.remove').hidden = s.status === 'running';
2506
+ const useButton = node.querySelector('.use-folder');
2507
+ useButton.hidden = !clonedPath(s);
2508
+ useButton.title = clonedPath(s) ? `Make ${s.clone.path} the working folder, so new sessions start there` : '';
1892
2509
  const resume = node.querySelector('.resume');
1893
2510
  resume.hidden = !resumable(s);
1894
2511
  resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
@@ -1938,6 +2555,7 @@ function guardLeaving() {
1938
2555
  function upsertSession(session) {
1939
2556
  state.sessions.set(session.id, session);
1940
2557
  renderSessions();
2558
+ noticeClone(session);
1941
2559
  }
1942
2560
 
1943
2561
  function dropSession(id) {
@@ -2207,6 +2825,7 @@ function enterStopping(running = 0, restart = false) {
2207
2825
  closeNews();
2208
2826
  closeChangelog();
2209
2827
  closeHistory();
2828
+ closeGitHub();
2210
2829
  for (const view of state.views.values()) view.dispose();
2211
2830
  state.views.clear();
2212
2831
  state.sessions.clear();
@@ -2325,8 +2944,11 @@ function connectEvents() {
2325
2944
  loadNews();
2326
2945
  // A changelog.updated sent while the socket was down is lost; catch up the open panel.
2327
2946
  if ($('changelog').open) loadChangelog();
2947
+ if ($('github').open) loadGitHub();
2328
2948
  } else if (msg.type === 'news.updated') {
2329
2949
  loadNews();
2950
+ } else if (msg.type === 'github.updated') {
2951
+ if ($('github').open) loadGitHub();
2330
2952
  } else if (msg.type === 'changelog.updated') {
2331
2953
  if ($('changelog').open) loadChangelog();
2332
2954
  } else if (msg.type === 'manager.upgrade') {
@@ -2390,6 +3012,7 @@ function showAuth(message = '') {
2390
3012
  closeNews();
2391
3013
  closeChangelog();
2392
3014
  closeHistory();
3015
+ closeGitHub();
2393
3016
  $('app').hidden = true;
2394
3017
  $('terminal-panel').hidden = true;
2395
3018
  $('stopped').hidden = true;
@@ -2448,6 +3071,30 @@ $('history').addEventListener('close', () => {
2448
3071
  historyOpener = null;
2449
3072
  });
2450
3073
  $('history-filter').addEventListener('input', renderHistory);
3074
+ $('github-open').addEventListener('click', openGitHub);
3075
+ $('github-close').addEventListener('click', () => closeGitHub());
3076
+ $('github').addEventListener('click', (e) => { if (e.target === $('github')) closeGitHub(); });
3077
+ $('github').addEventListener('close', () => {
3078
+ if (githubView.opener !== false) (githubView.opener?.isConnected && !githubView.opener.closest('[hidden]') ? githubView.opener : $('github-open')).focus();
3079
+ githubView.opener = null;
3080
+ });
3081
+ $('github-add').addEventListener('click', startGitHubSignIn);
3082
+ $('github-filter').addEventListener('input', () => renderGitHub());
3083
+ $('github-refresh').addEventListener('click', () => loadRepos({ refresh: true }));
3084
+ $('github-new').addEventListener('click', () => showCreate($('github-create').hidden));
3085
+ $('github-create').addEventListener('submit', createRepo);
3086
+ $('github-create-cancel').addEventListener('click', () => {
3087
+ showCreate(false);
3088
+ $('github-new').focus();
3089
+ });
3090
+ $('github-create').addEventListener('input', () => {
3091
+ $('github-create-error').hidden = true;
3092
+ renderCreate();
3093
+ });
3094
+ $('github-parent').addEventListener('change', () => {
3095
+ save(CLONE_PARENT_KEY, cloneParent());
3096
+ loadRepos();
3097
+ });
2451
3098
  $('history-here').addEventListener('change', renderHistory);
2452
3099
  $('history-form').addEventListener('submit', resumeById);
2453
3100
  $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
@@ -2561,9 +3208,11 @@ $('panel-stop').addEventListener('click', () => {
2561
3208
  });
2562
3209
  $('cwd').value = load(CWD_KEY) || '';
2563
3210
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
3211
+ githubView.accountId = Number(load(GITHUB_ACCOUNT_KEY)) || null;
2564
3212
  if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
2565
3213
  setInterval(renderSessions, 30000);
2566
3214
  setInterval(tickNews, 30000);
3215
+ setInterval(() => { if ($('github').open && state.github) renderGitHub(); }, 30000);
2567
3216
 
2568
3217
  // The terminal panel sits below the top bar, which wraps onto two rows on
2569
3218
  // narrow screens; publish its height so the panel never covers its controls.