@oddessentials/agent-guild 1.13.0 → 1.14.1
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/docs/api.md +850 -0
- 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 +3 -2
- package/src/manager/server.mjs +4 -0
- package/web/api-doc.js +30 -0
- package/web/api-explorer.js +164 -0
- package/web/api.html +40 -0
- package/web/yard/art.json +1 -1
|
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.14.1",
|
|
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
|
"mcpName": "ai.oddessentials/agent-guild",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"web/",
|
|
31
31
|
"!web/yard/assets/",
|
|
32
32
|
"config/",
|
|
33
|
-
"examples/"
|
|
33
|
+
"examples/",
|
|
34
|
+
"docs/api.md"
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|
|
36
37
|
"start": "node bin/agent-guild.mjs open",
|
package/src/manager/server.mjs
CHANGED
|
@@ -26,6 +26,8 @@ const MAX_BODY = 64 * 1024;
|
|
|
26
26
|
// A reply, and a Docker Agent stop that carries one, is a whole answer, which Claude Code does not clip.
|
|
27
27
|
const REPLY_BODY_LIMIT = 1024 * 1024;
|
|
28
28
|
const MAX_WS_PAYLOAD = 1024 * 1024;
|
|
29
|
+
// The API explorer (web/api.html) reads the doc itself.
|
|
30
|
+
const API_DOC = path.join(path.dirname(require.resolve('../../package.json')), 'docs', 'api.md');
|
|
29
31
|
const SLOW_CLIENT_BYTES = 8 * 1024 * 1024;
|
|
30
32
|
|
|
31
33
|
const MIME = {
|
|
@@ -33,6 +35,7 @@ const MIME = {
|
|
|
33
35
|
'.js': 'text/javascript; charset=utf-8',
|
|
34
36
|
'.mjs': 'text/javascript; charset=utf-8',
|
|
35
37
|
'.css': 'text/css; charset=utf-8',
|
|
38
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
36
39
|
'.svg': 'image/svg+xml',
|
|
37
40
|
'.png': 'image/png',
|
|
38
41
|
'.avif': 'image/avif',
|
|
@@ -271,6 +274,7 @@ export function createManagerServer({
|
|
|
271
274
|
throw new HttpError(405, 'method not allowed', 'method_not_allowed');
|
|
272
275
|
}
|
|
273
276
|
if (vendor[pathname]) return serveFile(req, res, vendor[pathname], { cache: true });
|
|
277
|
+
if (pathname === '/api.md') return serveFile(req, res, API_DOC);
|
|
274
278
|
let rel;
|
|
275
279
|
try {
|
|
276
280
|
rel = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '');
|
package/web/api-doc.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The API explorer's only list of routes is docs/api.md itself: its HTTP endpoint table, read here row by row.
|
|
2
|
+
|
|
3
|
+
// `| METHOD | `path` | body | result |`, where the body may be empty or hold an escaped `\|`.
|
|
4
|
+
const ROW = /^\| (GET|POST|PUT|PATCH|DELETE) \| `([^`]+)` \|((?:\\.|[^|\\])*)\| (.*) \|$/;
|
|
5
|
+
|
|
6
|
+
/** Every row of the HTTP endpoint table: `{ method, path, params, query, body, result }`, in the doc's order. */
|
|
7
|
+
export function endpoints(markdown) {
|
|
8
|
+
return markdown.split(/\r?\n/).map((line) => line.match(ROW)).filter(Boolean).map(([, method, route, body, result]) => {
|
|
9
|
+
const [path, search = ''] = route.split('?');
|
|
10
|
+
return {
|
|
11
|
+
method,
|
|
12
|
+
path,
|
|
13
|
+
params: [...path.matchAll(/:([a-z]+)/gi)].map(([, name]) => name),
|
|
14
|
+
query: search ? search.split('&').map((pair) => pair.split('=')[0]).filter(Boolean) : [],
|
|
15
|
+
body: body.trim().replaceAll('\\|', '|'),
|
|
16
|
+
result: result.trim(),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The GET routes the explorer sends. Each returns the manager's own state and starts no more work than opening
|
|
22
|
+
// the page does. The others are shown, not sent: some GETs run a tool, read files, or ask an outside service.
|
|
23
|
+
export const READS = new Set([
|
|
24
|
+
'/health', '/info', '/sign-in', '/remote-access', '/notes', '/providers', '/news', '/yard-art', '/changelog',
|
|
25
|
+
'/github', '/sessions', '/sessions/:id', '/sessions/:id/reply',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// The sockets the explorer listens to. It sends nothing on them, and a terminal is opened without `page`,
|
|
29
|
+
// so it can never claim or resize one.
|
|
30
|
+
export const SOCKETS = new Set(['/events', '/sessions/:id/terminal']);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// docs/api.md as a page, with its read-only routes and sockets live. Unlinked: a developer opens /api.html.
|
|
2
|
+
// Everything the manager answers is shown as text, never as HTML, and the token is never shown.
|
|
3
|
+
import markdownit from './vendor/markdown-it.mjs';
|
|
4
|
+
import { endpoints, READS, SOCKETS } from './api-doc.js';
|
|
5
|
+
|
|
6
|
+
const TOKEN_KEY = 'agentGuild.token';
|
|
7
|
+
const MAX_MESSAGES = 200;
|
|
8
|
+
const MAX_MESSAGE_CHARS = 2048;
|
|
9
|
+
|
|
10
|
+
const $ = (id) => document.getElementById(id);
|
|
11
|
+
const load = () => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } };
|
|
12
|
+
const save = (value) => { try { localStorage.setItem(TOKEN_KEY, value); } catch {} };
|
|
13
|
+
|
|
14
|
+
// The page's own sign-in, kept by the main page; a #token= link also works, and leaves the address at once.
|
|
15
|
+
let token = new URLSearchParams(location.hash.slice(1)).get('token');
|
|
16
|
+
if (token) {
|
|
17
|
+
save(token);
|
|
18
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
19
|
+
} else token = load();
|
|
20
|
+
|
|
21
|
+
function el(tag, props = {}, ...children) {
|
|
22
|
+
const node = Object.assign(document.createElement(tag), props);
|
|
23
|
+
node.append(...children);
|
|
24
|
+
return node;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// GitHub's heading ids, so the doc's links between its sections work here too.
|
|
28
|
+
const slug = (text) => text.toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').trim().replace(/\s/g, '-');
|
|
29
|
+
// The manager serves only this doc: a link to another one goes to it in the repository.
|
|
30
|
+
const DOCS = 'https://github.com/oddessentials/agent-guild/blob/main/docs/';
|
|
31
|
+
|
|
32
|
+
function render(markdown) {
|
|
33
|
+
const md = markdownit({ html: false });
|
|
34
|
+
md.core.ruler.push('heading_ids', (state) => {
|
|
35
|
+
state.tokens.forEach((t, i) => { if (t.type === 'heading_open') t.attrSet('id', slug(state.tokens[i + 1].content)); });
|
|
36
|
+
});
|
|
37
|
+
md.core.ruler.push('doc_links', (state) => {
|
|
38
|
+
for (const link of state.tokens.flatMap((t) => t.children ?? []).filter((t) => t.type === 'link_open')) {
|
|
39
|
+
const href = link.attrGet('href');
|
|
40
|
+
if (!href.startsWith('#') && !/^[a-z][a-z\d+.-]*:/i.test(href)) link.attrSet('href', new URL(href, DOCS).href);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
$('doc').innerHTML = md.render(markdown);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fill = (path, values) => path.replace(/:([a-z]+)/gi, (_, name) => encodeURIComponent(values[name]));
|
|
47
|
+
|
|
48
|
+
function fields(entry) {
|
|
49
|
+
const inputs = new Map();
|
|
50
|
+
for (const name of [...entry.params, ...entry.query]) {
|
|
51
|
+
const param = entry.params.includes(name);
|
|
52
|
+
const input = el('input', { name, required: param, placeholder: param ? `:${name}` : name, autocomplete: 'off', spellcheck: false });
|
|
53
|
+
if (name === 'id') input.setAttribute('list', 'session-ids');
|
|
54
|
+
inputs.set(name, input);
|
|
55
|
+
}
|
|
56
|
+
return inputs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function prettyJson(text) {
|
|
60
|
+
try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requestPanel(entry) {
|
|
64
|
+
const inputs = fields(entry);
|
|
65
|
+
const sent = el('p', { className: 'sent' });
|
|
66
|
+
const status = el('p', { className: 'status' });
|
|
67
|
+
const out = el('pre');
|
|
68
|
+
const form = el('form', {}, ...[...inputs].map(([name, input]) => el('label', {}, name, input)), el('button', { type: 'submit' }, `Send GET`));
|
|
69
|
+
form.addEventListener('submit', async (event) => {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
const values = Object.fromEntries([...inputs].map(([name, input]) => [name, input.value.trim()]));
|
|
72
|
+
const search = new URLSearchParams(entry.query.filter((name) => values[name]).map((name) => [name, values[name]]));
|
|
73
|
+
const url = `/api/v1${fill(entry.path, values)}${search.size ? `?${search}` : ''}`;
|
|
74
|
+
sent.textContent = `GET ${url} · Authorization: Bearer •••`;
|
|
75
|
+
status.textContent = 'Waiting…';
|
|
76
|
+
out.textContent = '';
|
|
77
|
+
const started = performance.now();
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store' });
|
|
80
|
+
const text = await res.text();
|
|
81
|
+
status.textContent = `${res.status} ${res.statusText} · ${Math.round(performance.now() - started)} ms`;
|
|
82
|
+
out.textContent = prettyJson(text);
|
|
83
|
+
} catch {
|
|
84
|
+
status.textContent = 'No answer: Agent Guild may have stopped.';
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return el('div', { className: 'try' }, form, sent, status, out);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function socketPanel(path) {
|
|
91
|
+
const inputs = fields({ params: [...path.matchAll(/:([a-z]+)/gi)].map(([, name]) => name), query: [] });
|
|
92
|
+
const button = el('button', { type: 'submit' }, 'Connect');
|
|
93
|
+
const status = el('p', { className: 'status' });
|
|
94
|
+
const log = el('pre');
|
|
95
|
+
const lines = [];
|
|
96
|
+
let socket = null;
|
|
97
|
+
const add = (text) => {
|
|
98
|
+
lines.push(text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)} … (${text.length} characters)` : text);
|
|
99
|
+
if (lines.length > MAX_MESSAGES) lines.shift();
|
|
100
|
+
log.textContent = lines.join('\n');
|
|
101
|
+
};
|
|
102
|
+
const form = el('form', {}, ...[...inputs].map(([name, input]) => el('label', {}, name, input)), button);
|
|
103
|
+
form.addEventListener('submit', (event) => {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
if (socket) return socket.close();
|
|
106
|
+
const values = Object.fromEntries([...inputs].map(([name, input]) => [name, input.value.trim()]));
|
|
107
|
+
const route = `/api/v1${fill(path, values)}`;
|
|
108
|
+
lines.length = 0;
|
|
109
|
+
log.textContent = '';
|
|
110
|
+
// Listening only: nothing is ever sent, and a terminal without `page` cannot claim or resize.
|
|
111
|
+
socket = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${route}?token=${encodeURIComponent(token)}`);
|
|
112
|
+
status.textContent = `Connecting to ${route}…`;
|
|
113
|
+
button.textContent = 'Disconnect';
|
|
114
|
+
socket.onopen = () => { status.textContent = `Listening to ${route}`; };
|
|
115
|
+
socket.onmessage = ({ data }) => add(typeof data === 'string' ? data : '(binary message)');
|
|
116
|
+
socket.onclose = ({ code }) => {
|
|
117
|
+
status.textContent = `Closed (${code})`;
|
|
118
|
+
button.textContent = 'Connect';
|
|
119
|
+
socket = null;
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
return el('div', { className: 'try' }, form, status, log);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function addPanels(markdown) {
|
|
126
|
+
const documented = new Map(endpoints(markdown).map((entry) => [`${entry.method} ${entry.path}`, entry]));
|
|
127
|
+
for (const row of $('doc').querySelectorAll('tbody tr')) {
|
|
128
|
+
const [method, route] = [...row.cells].map((cell) => cell.textContent.trim());
|
|
129
|
+
const entry = documented.get(`${method} ${route?.split('?')[0]}`);
|
|
130
|
+
if (!entry || entry.method !== 'GET') continue;
|
|
131
|
+
if (!READS.has(entry.path)) {
|
|
132
|
+
row.cells[row.cells.length - 1].append(el('span', { className: 'not-sent' }, 'Not sent from here: it can do more than read.'));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const cell = el('td', { colSpan: row.cells.length }, requestPanel(entry));
|
|
136
|
+
row.after(el('tr', {}, cell));
|
|
137
|
+
}
|
|
138
|
+
for (const heading of $('doc').querySelectorAll('h3')) {
|
|
139
|
+
const path = heading.textContent.match(/^GET \/api\/v1(\/\S+)$/)?.[1];
|
|
140
|
+
if (SOCKETS.has(path)) heading.after(socketPanel(path));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function listSessions() {
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch('/api/v1/sessions', { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store' });
|
|
147
|
+
if (res.status === 401) $('note').textContent = 'Agent Guild refused the sign-in this browser keeps. Open Agent Guild again, then reload.';
|
|
148
|
+
if (!res.ok) return;
|
|
149
|
+
const { sessions = [] } = await res.json();
|
|
150
|
+
$('session-ids').replaceChildren(...sessions.map((s) => el('option', { value: s.id, label: `${s.name ?? ''} (${s.status})` })));
|
|
151
|
+
} catch { /* each request says so itself */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const res = await fetch('./api.md', { cache: 'no-cache' }).catch(() => null);
|
|
155
|
+
if (!res?.ok) $('note').textContent = 'This Agent Guild does not serve its API doc.';
|
|
156
|
+
else {
|
|
157
|
+
const markdown = await res.text();
|
|
158
|
+
render(markdown);
|
|
159
|
+
if (!token) $('note').textContent = 'Open Agent Guild in this browser once, then reload: the explorer uses its sign-in.';
|
|
160
|
+
else {
|
|
161
|
+
addPanels(markdown);
|
|
162
|
+
listSessions();
|
|
163
|
+
}
|
|
164
|
+
}
|
package/web/api.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="description" content="The Agent Guild API, read from its doc, with its read-only routes and sockets live.">
|
|
7
|
+
<title>Agent Guild API</title>
|
|
8
|
+
<link rel="icon" href="/brand/favicon.png" type="image/png">
|
|
9
|
+
<style>
|
|
10
|
+
:root { color-scheme: light dark; --fg: #1c1c1e; --muted: #6b6b70; --bg: #fff; --panel: #f4f4f6; --line: #d9d9de; --accent: #0a5fd6; }
|
|
11
|
+
@media (prefers-color-scheme: dark) { :root { --fg: #e8e8ea; --muted: #9a9aa0; --bg: #141416; --panel: #1f1f23; --line: #34343a; --accent: #6aa6ff; } }
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
|
|
14
|
+
main { max-width: 1100px; margin: 0 auto; padding: 16px; }
|
|
15
|
+
#note { margin: 0 0 12px; padding: 10px 12px; border-left: 3px solid var(--accent); background: var(--panel); }
|
|
16
|
+
#note:empty { display: none; }
|
|
17
|
+
a { color: var(--accent); }
|
|
18
|
+
code, pre, input { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
19
|
+
table { display: block; overflow-x: auto; border-collapse: collapse; margin: 12px 0; }
|
|
20
|
+
th, td { border: 1px solid var(--line); padding: 6px 8px; text-align: left; vertical-align: top; }
|
|
21
|
+
.not-sent { display: block; margin-top: 4px; color: var(--muted); font-size: 13px; }
|
|
22
|
+
.try { margin: 6px 0 12px; padding: 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
|
|
23
|
+
.try form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
24
|
+
.try label { display: flex; gap: 6px; align-items: center; }
|
|
25
|
+
.try input { min-width: 14ch; padding: 4px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--fg); }
|
|
26
|
+
.try button { padding: 4px 12px; border: 1px solid var(--accent); border-radius: 4px; background: transparent; color: var(--accent); font: inherit; cursor: pointer; }
|
|
27
|
+
.try .sent, .try .status { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
|
|
28
|
+
.try pre { max-height: 420px; margin: 6px 0 0; padding: 8px; overflow: auto; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
29
|
+
.try pre:empty { display: none; }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<main>
|
|
34
|
+
<p id="note" role="status"></p>
|
|
35
|
+
<div id="doc"></div>
|
|
36
|
+
<datalist id="session-ids"></datalist>
|
|
37
|
+
</main>
|
|
38
|
+
<script type="module" src="./api-explorer.js"></script>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|