@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 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 is the `docker agent`
60
- plugin; Docker Desktop 4.63 and later ship it, and its card's **Install**
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
- On Linux, Docker Engine does not include the plugin; the card's **Install**
67
- adds it. The card needs Docker's own `docker` command: Podman's
68
- `docker` and the Ubuntu snap of Docker do not load the plugin. If you
69
- installed Docker Agent on its own as `docker-agent` (Homebrew or a release
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
- ```json
74
- { "providers": [{ "id": "docker", "command": "docker-agent", "args": ["run"], "versionArgs": ["version"] }] }
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "1.4.0",
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 and open pull requests of one repository, read with a
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. GitHub's own refusals become answers the page can show as they are. */
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
- if (err.limited) throw refusal(429, 'rate_limited', err.message);
139
- if (err.github === 404) throw refusal(404, 'not_found', `GitHub could not find ${repo.fullName} for @${account.login}`);
140
- if (err.github === 410) throw refusal(404, 'issues_disabled', `Issues are turned off for ${repo.fullName}`);
141
- if (err.github === 403) throw refusal(403, 'forbidden', `GitHub did not let @${account.login} do that in ${repo.fullName}`);
142
- if (err.github === 422) throw refusal(400, 'github_rejected', err.message);
143
- throw err;
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
  }
@@ -780,6 +780,7 @@ export class GitHub extends EventEmitter {
780
780
  if (body?.id === account.id && typeof body.login === 'string' && body.login !== account.login) {
781
781
  account.login = body.login;
782
782
  this._save();
783
+ this._changed();
783
784
  }
784
785
  }
785
786
 
@@ -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 route = chooseRoute(this.info, this.getTarget(), config.pending?.type === 'enable' ? config.pending.route : config.route);
131
- candidate = { url: routeUrl(route), existing: !config.route && !config.pending && routeMatches(this.info.config, route) };
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 = chooseRoute(info, this.getTarget(), previous); } catch (error) {
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 });
@@ -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
- history.replaceState(null, '', location.pathname + location.search);
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 === expected && type === 'view' && (view === 'cards' || view === 'yard')) showView?.(view);
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>