@oddessentials/agent-guild 1.4.0 → 1.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 +44 -29
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/package.json +1 -1
- package/src/manager/github-views.mjs +133 -9
- package/src/manager/github.mjs +1 -0
- package/src/manager/remote-access.mjs +24 -8
- package/src/manager/server.mjs +2 -1
- package/src/manager/tailscale.mjs +4 -1
- package/web/app.js +24 -8
- package/web/host.js +20 -2
- package/web/index.html +13 -0
- package/web/remote-access.js +14 -0
- package/web/theme.js +8 -2
- package/web/yard/art.json +1 -1
- package/web/yard/model.mjs +21 -2
- package/web/yard/renderer.js +12 -5
- package/web/yard/view.js +15 -6
- package/web/yard/yard.css +10 -0
package/README.md
CHANGED
|
@@ -12,27 +12,24 @@
|
|
|
12
12
|
<a href="https://oddessentials.ai/donate/?ref=agent-guild"><img src="https://img.shields.io/badge/support-buy%20the%20lab%20a%20beer-f2a93b" alt="Support Agent Guild: buy the lab a beer"></a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
|
+
<p align="center">
|
|
16
|
+
<picture>
|
|
17
|
+
<source media="(prefers-reduced-motion: reduce)" srcset="docs/images/yard-still.webp">
|
|
18
|
+
<img src="docs/images/yard-clip.webp" alt="Seven coding-agent sessions as characters in the Guild Yard: they leave the plaza for their tools' halls and get to work, then a double-click on one opens Claude Code's real terminal, with its three helper agents" width="100%">
|
|
19
|
+
</picture>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
**Every coding agent you run becomes a character in a 3D world, and stays a
|
|
23
|
+
real terminal one double-click away.**
|
|
24
|
+
|
|
15
25
|
**Agent Guild** runs Claude Code, Codex CLI, Antigravity CLI, Grok Build,
|
|
16
26
|
Docker Agent and your own shell side by side, in real terminals, from one
|
|
17
|
-
local web page.
|
|
27
|
+
local web page, or from your phone over Tailscale.
|
|
18
28
|
Close the page whenever you like; the sessions keep running.
|
|
19
29
|
|
|
20
30
|
[Try the demo](https://oddessentials.github.io/agent-guild/) in your browser
|
|
21
31
|
(simulated, nothing installed) or [watch the trailer](https://www.youtube.com/watch?v=ziT62WtXQ1M).
|
|
22
32
|
|
|
23
|
-
**[Agent Guild: World](https://agentguild.oddessentials.ai/)**, coming soon to
|
|
24
|
-
Steam, shows the same sessions as a crew at work in a living 3D world, on
|
|
25
|
-
Windows, macOS and Linux. It runs on Agent Guild, which stays free.
|
|
26
|
-
|
|
27
|
-
**[Agent Guild for VS Code](https://marketplace.visualstudio.com/items?itemName=OddEssentials.agent-guild)**
|
|
28
|
-
opens the same page in an editor tab. Editors that install from Open VSX get it
|
|
29
|
-
[there](https://open-vsx.org/extension/OddEssentials/agent-guild).
|
|
30
|
-
|
|
31
|
-
<picture>
|
|
32
|
-
<source media="(prefers-color-scheme: light)" srcset="docs/images/overview-light.webp">
|
|
33
|
-
<img src="docs/images/overview-dark.webp" alt="Agent Guild with one card per coding tool and six sessions at work">
|
|
34
|
-
</picture>
|
|
35
|
-
|
|
36
33
|
## Quick start
|
|
37
34
|
|
|
38
35
|
With Node.js 22 or newer:
|
|
@@ -46,6 +43,10 @@ The page opens in your browser. Press **New** on a tool's card to start a
|
|
|
46
43
|
session. Sign in to each tool inside its first session, as you would in any
|
|
47
44
|
terminal.
|
|
48
45
|
|
|
46
|
+
**[Agent Guild for VS Code](https://marketplace.visualstudio.com/items?itemName=OddEssentials.agent-guild)**
|
|
47
|
+
opens the same page in an editor tab. Editors that install from Open VSX get it
|
|
48
|
+
[there](https://open-vsx.org/extension/OddEssentials/agent-guild).
|
|
49
|
+
|
|
49
50
|
| Requirement | |
|
|
50
51
|
| --- | --- |
|
|
51
52
|
| Node.js | 22 or newer |
|
|
@@ -56,23 +57,18 @@ terminal.
|
|
|
56
57
|
No compiler is needed, except on Linux without glibc, such as Alpine:
|
|
57
58
|
`agent-guild` prints the commands that build its terminal library there. A
|
|
58
59
|
missing tool shows **Install** on its card; Antigravity CLI, which is not on
|
|
59
|
-
npm, shows its install command instead. Docker Agent
|
|
60
|
-
|
|
61
|
-
downloads the latest release into `~/.docker/cli-plugins` when no copy runs.
|
|
62
|
-
The card's installation box lists every copy the Docker CLI finds and which
|
|
63
|
-
one runs; the card updates and removes only the downloaded one, and leaves
|
|
64
|
-
Docker Desktop's to Desktop.
|
|
60
|
+
npm, shows its install command instead. For Docker Agent, see
|
|
61
|
+
[Docker Agent](#docker-agent).
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
download), add this to [`providers.json`](docs/configuration.md#providersjson),
|
|
71
|
-
then run `agent-guild restart`:
|
|
63
|
+
<picture>
|
|
64
|
+
<source media="(prefers-color-scheme: light)" srcset="docs/images/overview-light.webp">
|
|
65
|
+
<img src="docs/images/overview-dark.webp" alt="Agent Guild with one card per coding tool and six sessions at work">
|
|
66
|
+
</picture>
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
**[Agent Guild: World](https://agentguild.oddessentials.ai/)**, coming soon to
|
|
69
|
+
Steam, is the premium 3D window onto the same sessions: six worlds to roam
|
|
70
|
+
with a free camera, Steam Workshop crews and companions, and controller
|
|
71
|
+
support, on Windows, macOS and Linux. Agent Guild and its Yard stay free.
|
|
76
72
|
|
|
77
73
|
## Setup
|
|
78
74
|
|
|
@@ -86,6 +82,25 @@ To replace it, stop the manager (this ends running sessions), delete
|
|
|
86
82
|
`auth-token` from the [data folder](docs/configuration.md#data-folder), and
|
|
87
83
|
start again.
|
|
88
84
|
|
|
85
|
+
### Docker Agent
|
|
86
|
+
|
|
87
|
+
Docker Agent is the `docker agent` plugin; Docker Desktop 4.63 and later ship
|
|
88
|
+
it, and its card's **Install** downloads the latest release into
|
|
89
|
+
`~/.docker/cli-plugins` when no copy runs. The card's installation box lists
|
|
90
|
+
every copy the Docker CLI finds and which one runs; the card updates and
|
|
91
|
+
removes only the downloaded one, and leaves Docker Desktop's to Desktop.
|
|
92
|
+
|
|
93
|
+
On Linux, Docker Engine does not include the plugin; the card's **Install**
|
|
94
|
+
adds it. The card needs Docker's own `docker` command: Podman's
|
|
95
|
+
`docker` and the Ubuntu snap of Docker do not load the plugin. If you
|
|
96
|
+
installed Docker Agent on its own as `docker-agent` (Homebrew or a release
|
|
97
|
+
download), add this to [`providers.json`](docs/configuration.md#providersjson),
|
|
98
|
+
then run `agent-guild restart`:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{ "providers": [{ "id": "docker", "command": "docker-agent", "args": ["run"], "versionArgs": ["version"] }] }
|
|
102
|
+
```
|
|
103
|
+
|
|
89
104
|
### Headless Linux server
|
|
90
105
|
|
|
91
106
|
1. On the server:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"repository": {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// Branches, Issues, Actions runs
|
|
2
|
-
// signed-in account. Reading and editing stay shallow: GitHub's page is the full view.
|
|
1
|
+
// Branches, Issues, Actions runs, open pull requests and the account's own recent work in one
|
|
2
|
+
// repository, read with a signed-in account. Reading and editing stay shallow: GitHub's page is the full view.
|
|
3
3
|
|
|
4
4
|
import { nextLink, parseRepo } from './github.mjs';
|
|
5
5
|
|
|
6
6
|
const PAGE = 30;
|
|
7
|
+
const ACTIVITY_PAGES = 3;
|
|
8
|
+
const CLOSERS = 20;
|
|
7
9
|
const RUNNING = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
|
|
8
10
|
const WEB = 'https://github.com';
|
|
9
11
|
const ISSUE_RE = /^[1-9]\d{0,9}$/;
|
|
@@ -16,6 +18,16 @@ function refusal(status, code, message) {
|
|
|
16
18
|
return Object.assign(new Error(message), { status, code });
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
/** GitHub's own refusals become answers the page can show as they are. */
|
|
22
|
+
function requestError(err, account, repo) {
|
|
23
|
+
if (err.limited) return refusal(429, 'rate_limited', err.message);
|
|
24
|
+
if (err.github === 404) return refusal(404, 'not_found', `GitHub could not find ${repo.fullName} for @${account.login}`);
|
|
25
|
+
if (err.github === 410) return refusal(404, 'issues_disabled', `Issues are turned off for ${repo.fullName}`);
|
|
26
|
+
if (err.github === 403) return refusal(403, 'forbidden', `GitHub did not let @${account.login} do that in ${repo.fullName}`);
|
|
27
|
+
if (err.github === 422) return refusal(400, 'github_rejected', err.message);
|
|
28
|
+
return err;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/** A github.com page, or the fallback built from checked parts. */
|
|
20
32
|
export function githubUrl(value, fallback) {
|
|
21
33
|
try {
|
|
@@ -86,6 +98,40 @@ function cleanPull(raw, fullName) {
|
|
|
86
98
|
};
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
function idOf(user) {
|
|
102
|
+
return Number.isSafeInteger(user?.id) ? user.id : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function since(value, from) {
|
|
106
|
+
const at = Date.parse(value);
|
|
107
|
+
return Number.isFinite(at) && at >= from;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cleanMerged(raw, fullName) {
|
|
111
|
+
const number = raw?.number;
|
|
112
|
+
if (!Number.isSafeInteger(number) || number < 1) return null;
|
|
113
|
+
return {
|
|
114
|
+
repo: fullName,
|
|
115
|
+
number,
|
|
116
|
+
title: text(raw.title, 256).trim(),
|
|
117
|
+
mergedAt: time(raw.merged_at),
|
|
118
|
+
url: githubUrl(raw.html_url, `${WEB}/${fullName}/pull/${number}`),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cleanPassed(raw, fullName) {
|
|
123
|
+
const id = raw?.id;
|
|
124
|
+
if (!Number.isSafeInteger(id) || id < 1) return null;
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
name: text(raw.name, 200) || 'Workflow',
|
|
128
|
+
branch: text(raw.head_branch, 200),
|
|
129
|
+
event: text(raw.event, 40),
|
|
130
|
+
createdAt: time(raw.created_at),
|
|
131
|
+
url: githubUrl(raw.html_url, `${WEB}/${fullName}/actions/runs/${id}`),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
89
135
|
/** The Actions part of a githubstatus.com summary: null while it is operational or unreadable. */
|
|
90
136
|
export function actionsService(summary) {
|
|
91
137
|
const component = (Array.isArray(summary?.components) ? summary.components : []).find((c) => c?.name === 'Actions' && !c.group_id);
|
|
@@ -127,7 +173,7 @@ export function createViews(github, { now = Date.now } = {}) {
|
|
|
127
173
|
return read;
|
|
128
174
|
}
|
|
129
175
|
|
|
130
|
-
/** One request for the repository's account.
|
|
176
|
+
/** One request for the repository's account. */
|
|
131
177
|
async function call(accountId, owner, name, route, init) {
|
|
132
178
|
const account = github.account(accountId);
|
|
133
179
|
const repo = parseRepo(`${owner ?? ''}/${name ?? ''}`);
|
|
@@ -135,13 +181,21 @@ export function createViews(github, { now = Date.now } = {}) {
|
|
|
135
181
|
const { body, res } = await github._apiJson(account, `/repos/${repo.owner}/${repo.name}${route}`, init);
|
|
136
182
|
return { body, res, repo };
|
|
137
183
|
} catch (err) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
184
|
+
throw requestError(err, account, repo);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A listing's pages, newest first, until one reaches back before `from`, GitHub has no more, or ACTIVITY_PAGES are read. */
|
|
189
|
+
async function recent(accountId, owner, name, route, stamp, from, items = (body) => body) {
|
|
190
|
+
const found = [];
|
|
191
|
+
for (let page = 1; page <= ACTIVITY_PAGES; page++) {
|
|
192
|
+
const { body, res } = await call(accountId, owner, name, `${route}&per_page=100&page=${page}`);
|
|
193
|
+
const list = items(body);
|
|
194
|
+
if (!Array.isArray(list)) throw refusal(502, 'github_error', 'GitHub did not return a list');
|
|
195
|
+
found.push(...list);
|
|
196
|
+
if (!nextLink(res.headers.get('link')) || list.some((item) => !since(stamp(item), from))) return { found, truncated: false };
|
|
144
197
|
}
|
|
198
|
+
return { found, truncated: true };
|
|
145
199
|
}
|
|
146
200
|
|
|
147
201
|
return {
|
|
@@ -235,5 +289,75 @@ export function createViews(github, { now = Date.now } = {}) {
|
|
|
235
289
|
url: `${WEB}/${repo.fullName}/pulls`,
|
|
236
290
|
};
|
|
237
291
|
},
|
|
292
|
+
|
|
293
|
+
/** The account's own merged pull requests, issues closed as completed and successful runs, from `from` on. */
|
|
294
|
+
async activity(accountId, owner, name, { from: value } = {}) {
|
|
295
|
+
const from = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) ? Date.parse(value) : NaN;
|
|
296
|
+
if (!Number.isFinite(from)) throw refusal(400, 'bad_since', 'since must be a time such as 2026-10-01T00:00:00Z');
|
|
297
|
+
const account = github.account(accountId);
|
|
298
|
+
const repo = parseRepo(`${owner ?? ''}/${name ?? ''}`);
|
|
299
|
+
// The actor filter needs the current login; ownership is still checked by numeric id.
|
|
300
|
+
await github._refreshUser(account).catch((err) => { throw requestError(err, account, repo); });
|
|
301
|
+
const stamp = new Date(from).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
302
|
+
let truncated = false;
|
|
303
|
+
const merged = new Map();
|
|
304
|
+
const closedPulls = '/pulls?state=closed&sort=updated&direction=desc';
|
|
305
|
+
const mergeFrom = (list, fullName, head) => {
|
|
306
|
+
for (const pull of list) {
|
|
307
|
+
if (idOf(pull?.user) !== account.id || !since(pull.merged_at, from)) continue;
|
|
308
|
+
if (head !== null && (!Number.isSafeInteger(head) || pull.head?.repo?.id !== head)) continue;
|
|
309
|
+
const clean = cleanMerged(pull, fullName);
|
|
310
|
+
if (clean) merged.set(`${fullName.toLowerCase()}#${clean.number}`, clean);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const { body: metadata } = await call(accountId, repo.owner, repo.name, '');
|
|
315
|
+
const own = await recent(accountId, repo.owner, repo.name, closedPulls, (p) => p?.updated_at, from);
|
|
316
|
+
truncated ||= own.truncated;
|
|
317
|
+
mergeFrom(own.found, repo.fullName, null);
|
|
318
|
+
let parent = null;
|
|
319
|
+
try { parent = metadata?.fork === true ? parseRepo(metadata.parent?.full_name) : null; } catch { /* not a fork we can read */ }
|
|
320
|
+
if (parent) {
|
|
321
|
+
try {
|
|
322
|
+
const upstream = await recent(accountId, parent.owner, parent.name, closedPulls, (p) => p?.updated_at, from);
|
|
323
|
+
truncated ||= upstream.truncated;
|
|
324
|
+
mergeFrom(upstream.found, parent.fullName, metadata.id ?? NaN);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err.code !== 'not_found') throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const closed = new Map();
|
|
331
|
+
try {
|
|
332
|
+
const listed = await recent(accountId, repo.owner, repo.name, `/issues?state=closed&sort=updated&direction=desc&since=${stamp}`, (i) => i?.updated_at, from);
|
|
333
|
+
const candidates = listed.found.filter((i) => i && !i.pull_request && i.state_reason === 'completed' && since(i.closed_at, from) && ISSUE_RE.test(String(i.number)));
|
|
334
|
+
truncated ||= listed.truncated || candidates.length > CLOSERS;
|
|
335
|
+
for (const candidate of candidates.slice(0, CLOSERS)) {
|
|
336
|
+
// GitHub's docs promise closed_by only on the issue itself, so a listing without it is read again.
|
|
337
|
+
const issue = candidate.closed_by !== undefined ? candidate : await call(accountId, repo.owner, repo.name, `/issues/${candidate.number}`).then(({ body }) => body, (err) => {
|
|
338
|
+
if (err.code === 'not_found') return null;
|
|
339
|
+
throw err;
|
|
340
|
+
});
|
|
341
|
+
if (issue?.state !== 'closed' || issue.state_reason !== 'completed' || idOf(issue.closed_by) !== account.id || !since(issue.closed_at, from)) continue;
|
|
342
|
+
const clean = cleanIssue(issue, repo.fullName);
|
|
343
|
+
if (clean) closed.set(clean.number, { number: clean.number, title: clean.title, closedAt: time(issue.closed_at), url: clean.url });
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (err.code !== 'issues_disabled') throw err;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const runs = await recent(accountId, repo.owner, repo.name,
|
|
350
|
+
`/actions/runs?actor=${encodeURIComponent(account.login)}&status=success&created=${encodeURIComponent(`>=${stamp}`)}`,
|
|
351
|
+
(r) => r?.created_at, from, (body) => body?.workflow_runs);
|
|
352
|
+
truncated ||= runs.truncated;
|
|
353
|
+
const passed = new Map();
|
|
354
|
+
for (const run of runs.found) {
|
|
355
|
+
if (idOf(run?.actor) !== account.id || run.conclusion !== 'success' || !since(run.created_at, from)) continue;
|
|
356
|
+
const clean = cleanPassed(run, repo.fullName);
|
|
357
|
+
if (clean) passed.set(clean.id, clean);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { since: new Date(from).toISOString(), merged: [...merged.values()], closed: [...closed.values()], passed: [...passed.values()], truncated };
|
|
361
|
+
},
|
|
238
362
|
};
|
|
239
363
|
}
|
package/src/manager/github.mjs
CHANGED
|
@@ -26,6 +26,7 @@ function validateSaved(value) {
|
|
|
26
26
|
if (!value || value.version !== 1 || typeof value.revision !== 'string' || value.revision.length > 100
|
|
27
27
|
|| !['off', 'custom', 'tailscale'].includes(value.mode)) throw new Error('Unsupported remote-access settings.');
|
|
28
28
|
const route = value.route ? validateRoute(value.route) : null;
|
|
29
|
+
const lastRoute = value.lastRoute ? validateRoute(value.lastRoute) : null;
|
|
29
30
|
if (value.mode === 'tailscale' && !route) throw new Error('Missing saved Tailscale route.');
|
|
30
31
|
if (value.mode !== 'tailscale' && route) throw new Error('Unexpected saved Tailscale route.');
|
|
31
32
|
const access = value.mode === 'off' ? emptyAccess() : value.mode === 'tailscale' ? routeAccess(route) : normalizeAccess(value.access);
|
|
@@ -34,7 +35,7 @@ function validateSaved(value) {
|
|
|
34
35
|
if (!['enable', 'disable'].includes(value.pending.type)) throw new Error('Invalid pending remote-access operation.');
|
|
35
36
|
pending = { type: value.pending.type, route: validateRoute(value.pending.route), ...(value.pending.previous ? { previous: validateRoute(value.pending.previous) } : {}) };
|
|
36
37
|
}
|
|
37
|
-
return { version: 1, revision: value.revision, mode: value.mode, access, route, pending };
|
|
38
|
+
return { version: 1, revision: value.revision, mode: value.mode, access, route, lastRoute, pending };
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export function loadRemoteAccess(file, env = process.env) {
|
|
@@ -56,7 +57,7 @@ export function loadRemoteAccess(file, env = process.env) {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
function disabledConfig() {
|
|
59
|
-
return { version: 1, revision: randomUUID(), mode: 'off', access: emptyAccess(), route: null, pending: null };
|
|
60
|
+
return { version: 1, revision: randomUUID(), mode: 'off', access: emptyAccess(), route: null, lastRoute: null, pending: null };
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export function saveRemoteAccess(file, config) {
|
|
@@ -122,13 +123,25 @@ export class RemoteAccess extends EventEmitter {
|
|
|
122
123
|
this.getTarget = getTarget;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/** The route enabling would use: the remembered one unless `standard` asks for the usual order. */
|
|
127
|
+
select(info, previous, standard = false) {
|
|
128
|
+
return chooseRoute(info, this.getTarget(), previous, standard ? null : this.config.lastRoute);
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
snapshot() {
|
|
126
132
|
const config = this.config;
|
|
127
133
|
let candidate = null;
|
|
128
134
|
if (this.info && !this.busy && this.getTarget()) {
|
|
129
135
|
try {
|
|
130
|
-
const
|
|
131
|
-
|
|
136
|
+
const previous = config.pending?.type === 'enable' ? config.pending.route : config.route;
|
|
137
|
+
const route = this.select(this.info, previous);
|
|
138
|
+
const fresh = !config.route && !config.pending;
|
|
139
|
+
const existing = fresh && routeMatches(this.info.config, route);
|
|
140
|
+
candidate = { url: routeUrl(route), existing, remembered: fresh && !existing && Boolean(config.lastRoute) && routeUrl(config.lastRoute) === routeUrl(route) };
|
|
141
|
+
if (fresh) {
|
|
142
|
+
const standard = routeUrl(this.select(this.info, null, true));
|
|
143
|
+
if (standard !== candidate.url) candidate.standardUrl = standard;
|
|
144
|
+
}
|
|
132
145
|
} catch {}
|
|
133
146
|
}
|
|
134
147
|
return {
|
|
@@ -201,6 +214,9 @@ export class RemoteAccess extends EventEmitter {
|
|
|
201
214
|
if (this.busy) throw remoteError('remote_busy', 'A remote-access operation is already in progress.');
|
|
202
215
|
if (body.revision !== this.config.revision) throw remoteError('stale_settings', 'Remote access changed in another tab. Review the current settings and try again.');
|
|
203
216
|
if (!['enable', 'disable', 'custom', 'forget'].includes(body.action)) throw remoteError('bad_action', 'Choose a valid remote-access action.', 400);
|
|
217
|
+
if (body.standard !== undefined && (body.standard !== true || body.action !== 'enable' || this.config.route || this.config.pending)) {
|
|
218
|
+
throw remoteError('bad_action', 'Switch to the standard address while remote access is off.', 400);
|
|
219
|
+
}
|
|
204
220
|
let access;
|
|
205
221
|
if (body.action === 'custom') {
|
|
206
222
|
if (this.config.route || this.config.pending) throw remoteError('disable_first', 'Disable the Tailscale connection before changing custom proxy settings.');
|
|
@@ -214,7 +230,7 @@ export class RemoteAccess extends EventEmitter {
|
|
|
214
230
|
this.connection = null;
|
|
215
231
|
this.publish();
|
|
216
232
|
this.task = Promise.resolve().then(async () => {
|
|
217
|
-
if (body.action === 'enable') await this.enable(body.adopt === true);
|
|
233
|
+
if (body.action === 'enable') await this.enable(body.adopt === true, body.standard === true);
|
|
218
234
|
else if (body.action === 'disable') await this.disable();
|
|
219
235
|
else if (body.action === 'custom') this.persist({ ...this.config, mode: access.hosts.length || access.origins.length ? 'custom' : 'off', access, route: null, pending: null });
|
|
220
236
|
else this.persist({ ...this.config, pending: null });
|
|
@@ -227,13 +243,13 @@ export class RemoteAccess extends EventEmitter {
|
|
|
227
243
|
return this.snapshot();
|
|
228
244
|
}
|
|
229
245
|
|
|
230
|
-
async enable(adopt) {
|
|
246
|
+
async enable(adopt, standard = false) {
|
|
231
247
|
if (this.config.pending?.type === 'disable') throw remoteError('cleanup_required', 'Finish cleanup of the previous route before enabling remote access.');
|
|
232
248
|
const info = await this.tailscale.inspect({ signal: this.abort.signal });
|
|
233
249
|
this.info = info;
|
|
234
250
|
let previous = this.config.pending?.type === 'enable' ? this.config.pending.route : this.config.route;
|
|
235
251
|
let route;
|
|
236
|
-
try { route =
|
|
252
|
+
try { route = this.select(info, previous, standard); } catch (error) {
|
|
237
253
|
const fallback = this.config.pending?.previous || this.config.route;
|
|
238
254
|
if (!fallback || !routeMatches(info.config, fallback)) throw error;
|
|
239
255
|
previous = fallback;
|
|
@@ -264,7 +280,7 @@ export class RemoteAccess extends EventEmitter {
|
|
|
264
280
|
async disable() {
|
|
265
281
|
const route = this.config.pending?.route || this.config.route;
|
|
266
282
|
const previous = this.config.pending?.previous || this.config.route;
|
|
267
|
-
this.persist({ ...this.config, mode: 'off', access: emptyAccess(), route: null, pending: route ? { type: 'disable', route, ...(previous ? { previous } : {}) } : null });
|
|
283
|
+
this.persist({ ...this.config, mode: 'off', access: emptyAccess(), route: null, lastRoute: this.config.route || this.config.lastRoute, pending: route ? { type: 'disable', route, ...(previous ? { previous } : {}) } : null });
|
|
268
284
|
if (route) {
|
|
269
285
|
try {
|
|
270
286
|
const info = await this.tailscale.inspect({ signal: this.abort.signal });
|
package/src/manager/server.mjs
CHANGED
|
@@ -587,7 +587,7 @@ export function createManagerServer({
|
|
|
587
587
|
const folder = manager.resolveCwd(url.searchParams.get('cwd'));
|
|
588
588
|
return sendJson(res, 200, { folder, repo: folderOrigin(folder) });
|
|
589
589
|
}
|
|
590
|
-
const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls|branches)(?:\/([^/]+))?$/);
|
|
590
|
+
const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls|branches|activity)(?:\/([^/]+))?$/);
|
|
591
591
|
if (view) {
|
|
592
592
|
let parts;
|
|
593
593
|
try { parts = view.map((part) => (part === undefined ? part : decodeURIComponent(part))); } catch { throw new HttpError(400, 'repo must be a GitHub repository written as owner/name', 'bad_repo'); }
|
|
@@ -604,6 +604,7 @@ export function createManagerServer({
|
|
|
604
604
|
if (kind === 'branches' && number === undefined && method === 'GET') return sendJson(res, 200, await views.branches(id, owner, name, { page: url.searchParams.get('page') ?? '1' }));
|
|
605
605
|
if (kind === 'actions' && number === undefined && method === 'GET') return sendJson(res, 200, await views.actions(id, owner, name));
|
|
606
606
|
if (kind === 'pulls' && number === undefined && method === 'GET') return sendJson(res, 200, await views.pulls(id, owner, name));
|
|
607
|
+
if (kind === 'activity' && number === undefined && method === 'GET') return sendJson(res, 200, await views.activity(id, owner, name, { from: url.searchParams.get('since') }));
|
|
607
608
|
}
|
|
608
609
|
const match = route.match(/^\/github\/accounts\/([^/]+)(\/repos|\/ssh)?$/);
|
|
609
610
|
if (match) {
|
|
@@ -121,7 +121,7 @@ export function routeMatches(config, route) {
|
|
|
121
121
|
} catch { return false; }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
export function chooseRoute(info, target, previous = null) {
|
|
124
|
+
export function chooseRoute(info, target, previous = null, remembered = null) {
|
|
125
125
|
if (previous) {
|
|
126
126
|
if (previous.nodeId !== info.nodeId || previous.host !== info.host) {
|
|
127
127
|
throw remoteError('network_changed', 'This computer’s Tailscale identity or address changed. Disable the saved connection before setting up the new address.');
|
|
@@ -136,6 +136,9 @@ export function chooseRoute(info, target, previous = null) {
|
|
|
136
136
|
const route = { nodeId: info.nodeId, host: info.host, port, target };
|
|
137
137
|
if (Number.isInteger(port) && port > 0 && port <= 65535 && routeMatches(info.config, route)) return route;
|
|
138
138
|
}
|
|
139
|
+
if (remembered?.nodeId === info.nodeId && remembered.host === info.host && !portOccupied(info.config, remembered.port)) {
|
|
140
|
+
return { nodeId: info.nodeId, host: info.host, port: remembered.port, target };
|
|
141
|
+
}
|
|
139
142
|
const port = [443, ...Array.from({ length: 32 }, (_, i) => 8443 + i)].find((candidate) => !portOccupied(info.config, candidate));
|
|
140
143
|
if (!port) throw remoteError('no_port', 'No available HTTPS port was found. Free a Serve port in Tailscale and try again.');
|
|
141
144
|
return { nodeId: info.nodeId, host: info.host, port, target };
|
package/web/app.js
CHANGED
|
@@ -14,7 +14,7 @@ import { createReader } from './read-aloud.js';
|
|
|
14
14
|
import { matchFolders, readRecentFolders, rememberFolder } from './folders.js';
|
|
15
15
|
import { initYard } from './yard/view.js';
|
|
16
16
|
import { ask } from './ask.js';
|
|
17
|
-
import { framed, openLink, announceReady, copyToClipboard, onViewRequest } from './host.js';
|
|
17
|
+
import { framed, compact, openLink, announceReady, expand, copyToClipboard, onViewRequest, onSessionRequest } from './host.js';
|
|
18
18
|
|
|
19
19
|
const TOKEN_KEY = 'agentGuild.token';
|
|
20
20
|
const CWD_KEY = 'agentGuild.cwd';
|
|
@@ -7069,7 +7069,7 @@ function savePanes() {
|
|
|
7069
7069
|
}
|
|
7070
7070
|
|
|
7071
7071
|
function restorePanes() {
|
|
7072
|
-
if (panesRestored) return;
|
|
7072
|
+
if (panesRestored || compact) return;
|
|
7073
7073
|
panesRestored = true;
|
|
7074
7074
|
let saved;
|
|
7075
7075
|
try { saved = JSON.parse(load(PANES_KEY)); } catch { return; }
|
|
@@ -7519,8 +7519,9 @@ function connectEvents() {
|
|
|
7519
7519
|
managerConnected(msg);
|
|
7520
7520
|
state.environmentUI?.connected(msg.pid);
|
|
7521
7521
|
renderSessions();
|
|
7522
|
-
if (!sessionsShown && load(DOCK_KEY) === 'github' && !dockView.panel) openGitHub({ focus: false });
|
|
7522
|
+
if (!sessionsShown && !compact && load(DOCK_KEY) === 'github' && !dockView.panel) openGitHub({ focus: false });
|
|
7523
7523
|
restorePanes();
|
|
7524
|
+
openAskedSession();
|
|
7524
7525
|
sessionsShown = true;
|
|
7525
7526
|
renderEmpty();
|
|
7526
7527
|
setUpgrade(msg.upgrade, true);
|
|
@@ -7559,6 +7560,7 @@ function connectEvents() {
|
|
|
7559
7560
|
managerGone();
|
|
7560
7561
|
} else if (msg.type === 'session.created' || msg.type === 'session.updated') {
|
|
7561
7562
|
upsertSession(msg.session);
|
|
7563
|
+
openAskedSession();
|
|
7562
7564
|
} else if (msg.type === 'session.removed') {
|
|
7563
7565
|
dropSession(msg.sessionId);
|
|
7564
7566
|
} else if (msg.type === 'providers.updated') {
|
|
@@ -7663,8 +7665,9 @@ function readTokenFromHash() {
|
|
|
7663
7665
|
const params = new URLSearchParams(location.hash.slice(1));
|
|
7664
7666
|
const token = params.get('token');
|
|
7665
7667
|
if (token) {
|
|
7666
|
-
// Keep the token out of the address bar and browser history.
|
|
7667
|
-
|
|
7668
|
+
// Keep the token out of the address bar and browser history. A compact page keeps the rest, which it does not save.
|
|
7669
|
+
params.delete('token');
|
|
7670
|
+
history.replaceState(null, '', location.pathname + location.search + (compact ? `#${params}` : ''));
|
|
7668
7671
|
}
|
|
7669
7672
|
return token;
|
|
7670
7673
|
}
|
|
@@ -7707,7 +7710,7 @@ async function boot() {
|
|
|
7707
7710
|
}
|
|
7708
7711
|
save(TOKEN_KEY, state.token);
|
|
7709
7712
|
$('app').hidden = false;
|
|
7710
|
-
if (load(DOCK_KEY) === 'notes' && !dockView.panel) openNotes({ focus: false });
|
|
7713
|
+
if (!compact && load(DOCK_KEY) === 'notes' && !dockView.panel) openNotes({ focus: false });
|
|
7711
7714
|
connectEvents();
|
|
7712
7715
|
loadUsage();
|
|
7713
7716
|
clearInterval(usageTimer);
|
|
@@ -8198,7 +8201,7 @@ renderVoice();
|
|
|
8198
8201
|
renderRead();
|
|
8199
8202
|
// Follow the system setting until the user picks a theme.
|
|
8200
8203
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
8201
|
-
if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
|
|
8204
|
+
if (!compact && !load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
|
|
8202
8205
|
});
|
|
8203
8206
|
$('panel-stop').addEventListener('click', () => {
|
|
8204
8207
|
const s = state.sessions.get(state.activeId);
|
|
@@ -8299,10 +8302,12 @@ yardUi = initYard({
|
|
|
8299
8302
|
providers: state.providers,
|
|
8300
8303
|
sessions: [...state.sessions.values()],
|
|
8301
8304
|
connected: state.connected,
|
|
8305
|
+
retrying: state.eventsRetry > 0,
|
|
8302
8306
|
news: state.news,
|
|
8303
8307
|
activeId: state.activeId,
|
|
8304
8308
|
}),
|
|
8305
|
-
openSession: openPanel,
|
|
8309
|
+
openSession: compact ? expand : openPanel,
|
|
8310
|
+
expand: compact ? () => expand() : null,
|
|
8306
8311
|
openNews,
|
|
8307
8312
|
selected: dockMakesWayForYard,
|
|
8308
8313
|
yardArt: () => state.yardArt,
|
|
@@ -8356,6 +8361,17 @@ onViewRequest((view) => {
|
|
|
8356
8361
|
if (!$('terminal-panel').hidden) closePanel();
|
|
8357
8362
|
yardUi.setView(view);
|
|
8358
8363
|
});
|
|
8364
|
+
let askedSession = null;
|
|
8365
|
+
function openAskedSession() {
|
|
8366
|
+
if (!askedSession || !state.sessions.has(askedSession)) return;
|
|
8367
|
+
const id = askedSession;
|
|
8368
|
+
askedSession = null;
|
|
8369
|
+
openPanel(id);
|
|
8370
|
+
}
|
|
8371
|
+
onSessionRequest((id) => {
|
|
8372
|
+
askedSession = id;
|
|
8373
|
+
if (state.connected && state.eventsSocket?.greeted) openAskedSession();
|
|
8374
|
+
});
|
|
8359
8375
|
|
|
8360
8376
|
state.token = readTokenFromHash() || load(TOKEN_KEY);
|
|
8361
8377
|
boot();
|
package/web/host.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export const framed = typeof window !== 'undefined' && window.parent !== window;
|
|
6
6
|
const app = typeof window !== 'undefined' && !framed ? window.webkit?.messageHandlers?.agentGuild ?? null : null;
|
|
7
7
|
const hosted = framed || Boolean(app);
|
|
8
|
+
export const compact = hosted && document.documentElement?.dataset.layout === 'compact';
|
|
8
9
|
|
|
9
10
|
function post(message) {
|
|
10
11
|
// The manager's frame-ancestors lets only VS Code webviews frame the page.
|
|
@@ -35,20 +36,37 @@ export function announceReady(version) {
|
|
|
35
36
|
if (hosted) post({ type: 'ready', version });
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
export function expand(session) {
|
|
40
|
+
if (hosted) post(session ? { type: 'expand', session } : { type: 'expand' });
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
let showView = null;
|
|
44
|
+
let showSession = null;
|
|
39
45
|
|
|
40
46
|
export function onViewRequest(callback) {
|
|
41
47
|
showView = callback;
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
export function onSessionRequest(callback) {
|
|
51
|
+
showSession = callback;
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
if (hosted) {
|
|
45
55
|
// The app posts its request into the page itself, so a subframe such as the video player cannot pass for it.
|
|
46
56
|
const [sender, expected] = framed ? [window.parent, 'agent-guild-vscode'] : [window, 'agent-guild-app'];
|
|
47
57
|
window.addEventListener('message', (event) => {
|
|
48
58
|
if (event.source !== sender || (app && event.origin !== location.origin)) return;
|
|
49
|
-
const { source, type, view } = event.data ?? {};
|
|
50
|
-
if (source
|
|
59
|
+
const { source, type, view, id } = event.data ?? {};
|
|
60
|
+
if (source !== expected) return;
|
|
61
|
+
if (type === 'view' && (view === 'cards' || view === 'yard')) showView?.(view);
|
|
62
|
+
if (type === 'session' && typeof id === 'string') showSession?.(id);
|
|
51
63
|
});
|
|
64
|
+
if (framed && !compact) {
|
|
65
|
+
const root = document.documentElement;
|
|
66
|
+
const look = () => post({ type: 'look', skin: root.dataset.skin, theme: root.dataset.theme });
|
|
67
|
+
new MutationObserver(look).observe(root, { attributes: true, attributeFilter: ['data-skin', 'data-theme'] });
|
|
68
|
+
look();
|
|
69
|
+
}
|
|
52
70
|
const follow = (event) => {
|
|
53
71
|
if (event.button > 1) return;
|
|
54
72
|
const link = event.target.closest?.('a[target="_blank"][href]');
|
package/web/index.html
CHANGED
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
<div id="yard-labels" class="yard-labels"></div>
|
|
133
133
|
<div id="yard-loading" class="yard-loading" role="status" data-mode="scene"><span id="yard-loading-text">Preparing your yard…</span><span id="yard-loading-size" class="yard-loading-size" aria-hidden="true"></span><span class="yard-loading-bar" aria-hidden="true"></span></div>
|
|
134
134
|
<div id="yard-swap-failure" class="yard-swap-failure" hidden role="status"><span id="yard-swap-failure-text"></span><button id="yard-swap-retry" class="btn" type="button">Retry</button></div>
|
|
135
|
+
<div id="yard-offline" class="yard-swap-failure" hidden role="status"><span id="yard-offline-text"></span><button id="yard-offline-open" class="btn" type="button">Open Agent Guild</button></div>
|
|
135
136
|
<div id="yard-failure" class="yard-failure" hidden role="status"><strong>The 3D view is unavailable</strong><p>You can still use every action in the roster and inspector.</p><button id="yard-retry" class="btn" type="button">Retry 3D</button><button id="yard-cards" class="btn" type="button">Show Cards</button></div>
|
|
136
137
|
<div id="yard-download" class="yard-failure yard-download" hidden role="region" aria-labelledby="yard-download-title"><strong id="yard-download-title" role="status"></strong><p id="yard-download-text"></p><progress id="yard-download-progress" aria-labelledby="yard-download-title"></progress><span id="yard-download-size" class="yard-loading-size"></span><div class="yard-download-actions"><button id="yard-download-cancel" class="btn" type="button">Cancel</button><button id="yard-download-retry" class="btn" type="button" hidden>Retry</button><button id="yard-download-cards" class="btn" type="button">Show Cards</button></div></div>
|
|
137
138
|
<div class="yard-camera" role="group" aria-label="Camera"><button id="yard-zoom-in" type="button" class="btn" aria-label="Zoom in">+</button><button id="yard-zoom-out" type="button" class="btn" aria-label="Zoom out">−</button><button id="yard-fit" type="button" class="btn">Overview</button><button id="yard-focus" type="button" class="btn" disabled>Focus selected</button></div>
|
|
@@ -390,6 +391,17 @@
|
|
|
390
391
|
<p id="remote-status-text"></p>
|
|
391
392
|
</div>
|
|
392
393
|
<p id="remote-error" class="remote-error" role="alert" hidden></p>
|
|
394
|
+
<div id="remote-next-row" hidden>
|
|
395
|
+
<p id="remote-next" class="sub"></p>
|
|
396
|
+
<button id="remote-standard" class="btn" type="button" hidden>Switch to standard address</button>
|
|
397
|
+
</div>
|
|
398
|
+
<div id="remote-standard-confirm" class="remote-confirm" hidden>
|
|
399
|
+
<p id="remote-standard-text"></p>
|
|
400
|
+
<div class="remote-actions">
|
|
401
|
+
<button id="remote-standard-yes" class="btn primary" type="button">Switch to standard address</button>
|
|
402
|
+
<button id="remote-standard-no" class="btn" type="button">Keep previous address</button>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
393
405
|
<div id="remote-address-row" hidden>
|
|
394
406
|
<label for="remote-address">Your remote address</label>
|
|
395
407
|
<input id="remote-address" class="remote-address" readonly spellcheck="false">
|
|
@@ -398,6 +410,7 @@
|
|
|
398
410
|
<button id="remote-copy-address" class="btn" type="button">Copy address</button>
|
|
399
411
|
<a id="remote-open-address" class="btn" target="_blank" rel="noopener noreferrer">Open</a>
|
|
400
412
|
</div>
|
|
413
|
+
<p id="remote-port-note" class="sub" hidden>To use the standard address, disable remote access, then choose Switch to standard address.</p>
|
|
401
414
|
</div>
|
|
402
415
|
<div id="remote-share" class="remote-share" hidden>
|
|
403
416
|
<p>Connect the other device to Tailscale, then scan this code or open the sign-in link.</p>
|