@oddessentials/agent-guild 1.14.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.
@@ -67,6 +67,7 @@
67
67
  ],
68
68
  "PermissionRequest": [
69
69
  {
70
+ "matcher": "Bash|PowerShell|Monitor",
70
71
  "hooks": [
71
72
  {
72
73
  "type": "command",
@@ -78,6 +79,7 @@
78
79
  ],
79
80
  "PermissionDenied": [
80
81
  {
82
+ "matcher": "Bash|PowerShell|Monitor",
81
83
  "hooks": [
82
84
  {
83
85
  "type": "command",
@@ -89,6 +91,7 @@
89
91
  ],
90
92
  "PostToolUse": [
91
93
  {
94
+ "matcher": "Bash|PowerShell|Monitor|TaskStop",
92
95
  "hooks": [
93
96
  {
94
97
  "type": "command",
@@ -100,6 +103,7 @@
100
103
  ],
101
104
  "PostToolUseFailure": [
102
105
  {
106
+ "matcher": "Bash|PowerShell|Monitor",
103
107
  "hooks": [
104
108
  {
105
109
  "type": "command",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "1.14.0",
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",
@@ -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(/^\/+/, '');
@@ -302,7 +306,7 @@ export function createManagerServer({
302
306
  // Agent, model and tool-session reports may authenticate with the
303
307
  // per-session report token that the manager injects into each tool's
304
308
  // environment.
305
- const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session|context|reporting|shells|docker|reply|permission|turn)$/);
309
+ const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session|context|reporting|shells|docker|reply)$/);
306
310
  if (reportMatch && method === 'POST') {
307
311
  const [, id, kind] = reportMatch;
308
312
  const body = await readJsonBody(req, kind === 'reply' || kind === 'docker' ? REPLY_BODY_LIMIT : MAX_BODY);
@@ -323,8 +327,6 @@ export function createManagerServer({
323
327
  manager.reportShell(id, body, auth);
324
328
  return sendJson(res, 200, { ok: true });
325
329
  }
326
- if (kind === 'permission') return sendJson(res, 200, { permission: manager.reportPermission(id, body, auth) });
327
- if (kind === 'turn') return sendJson(res, 200, { turn: manager.reportTurn(id, body, auth) });
328
330
  return sendJson(res, 200, { toolSessionId: manager.reportToolSession(id, body, auth) });
329
331
  }
330
332
 
@@ -16,12 +16,11 @@ const handler = (extra = {}) => ({ type: 'command', command: REPORT_COMMAND, ...
16
16
 
17
17
  const shellEvents = (events, matcher) => Object.fromEntries(events.map((event) => [event, { group: { matcher } }]));
18
18
 
19
- // Shell commands and monitors are the only tool calls whose start is reported. StopFailure (an API error) ends a turn
20
- // like Stop, and PermissionDenied (auto mode) ends a command that PreToolUse started, since no PostToolUse follows a
21
- // denial. A permission request for any tool is reported, and so is the end of any call, which answers it.
19
+ // Shell commands and monitors are the only tool calls reported. StopFailure (an API error) ends a turn like Stop, and
20
+ // PermissionDenied (auto mode) ends a command that PreToolUse started, since no PostToolUse follows a denial.
22
21
  const CLAUDE_SHELL_TOOLS = 'Bash|PowerShell|Monitor';
23
22
  const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'SubagentStart', 'SubagentStop', 'PostModelSwitch', 'PreToolUse', 'PermissionRequest', 'PermissionDenied', 'PostToolUse', 'PostToolUseFailure', 'Stop', 'StopFailure', 'PostCompact'];
24
- const CLAUDE_MATCHERS = { PreToolUse: CLAUDE_SHELL_TOOLS };
23
+ const CLAUDE_MATCHERS = { PreToolUse: CLAUDE_SHELL_TOOLS, PermissionRequest: CLAUDE_SHELL_TOOLS, PermissionDenied: CLAUDE_SHELL_TOOLS, PostToolUse: `${CLAUDE_SHELL_TOOLS}|TaskStop`, PostToolUseFailure: CLAUDE_SHELL_TOOLS };
25
24
  const CLAUDE_BLOCKING = new Set(['SubagentStart', 'PreToolUse']);
26
25
  export const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'SubagentStart', 'SubagentStop', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'Stop', 'Interrupt', 'SessionEnd', 'PostCompact'];
27
26
  const CODEX_MATCHERS = { PermissionRequest: 'Bash', PostToolUse: 'Bash' };
@@ -811,14 +811,6 @@ export class SessionManager extends EventEmitter {
811
811
  return this._reportingSession(id, auth).reportShell(report);
812
812
  }
813
813
 
814
- reportPermission(id, report, auth) {
815
- return this._reportingSession(id, auth).reportPermission(report);
816
- }
817
-
818
- reportTurn(id, report, auth) {
819
- return this._reportingSession(id, auth).reportTurn(report);
820
- }
821
-
822
814
  /**
823
815
  * A Docker Agent hook event. A session that was not started as Docker Agent (a Shell card the user ran it in, with
824
816
  * its own hooks) becomes one at its first event; the first Docker session heard from is then its main one.
@@ -29,12 +29,6 @@ const SHELL_EVENTS = new Set(['start', 'waiting', 'asked', 'background', 'end',
29
29
  const SHELL_KINDS = new Set(['shell', 'monitor']);
30
30
  const MAX_SHELLS = 256;
31
31
  const MAX_ENDED_TASKS = 256;
32
- const PERMISSION_EVENTS = new Set(['request', 'done']);
33
- const TURN_STATES = new Set(['working', 'ended']);
34
- // Claude Code's PermissionRequest means the user is asked; Codex CLI's may be answered by its Guardian reviewer.
35
- const PERMISSION_REPORTING = new Set(['claude']);
36
- const TURN_REPORTING = new Set(['claude', 'codex']);
37
- const MAX_PERMISSION_CLEARS = 256;
38
32
  // Keys and reports a terminal sends as escape sequences: X10 mouse reports (three raw bytes after ESC [ M), other CSI
39
33
  // and SS3 sequences, OSC and DCS strings (to their end, or the next ESC, so a large paste is scanned once), and ESC
40
34
  // with one character (Alt+key). Bracketed paste markers are CSI, so pasted text is kept.
@@ -126,16 +120,6 @@ export class Session extends EventEmitter {
126
120
  this.toolSessionId = null;
127
121
  this.reply = null;
128
122
  this._replies = 0;
129
- this._reportingActiveAt = null;
130
- // Agent Guild's own hooks, not ones the user added, which may not cover every tool.
131
- this._hooksSupplied = this.reporting?.state === 'pending';
132
- // Open permission requests, by agent ('' for the main one) and call, each at its latest request; and when each
133
- // call and each agent's requests were last answered, so a request that arrives after its own end stays closed.
134
- this._permissions = new Map();
135
- this._permissionClears = new Map();
136
- this._agentPermissionClears = new Map();
137
- this._permissionChangedAt = null;
138
- this.turn = null;
139
123
  this.context = { compactions: count(opts.context?.compactions), resets: count(opts.context?.resets) };
140
124
  this.compactionRepeatMs = opts.compactionRepeatMs ?? 2000;
141
125
  this._compactionKeys = [];
@@ -508,10 +492,7 @@ export class Session extends EventEmitter {
508
492
  }
509
493
  const now = new Date().toISOString();
510
494
  const existing = this.agents.get(id);
511
- if (status === 'done') {
512
- this._endForegroundShells(id);
513
- this._clearAgentPermissions(id, reportTime(report.at));
514
- }
495
+ if (status === 'done') this._endForegroundShells(id);
515
496
  // A first report that already says done would only flash an icon:
516
497
  // Claude Code's internal helpers (prompt suggestions, side questions)
517
498
  // stop without ever having started here.
@@ -801,75 +782,9 @@ export class Session extends EventEmitter {
801
782
  if (report.reply !== null && typeof report.reply !== 'string') throw badRequest('reply must be a string or null');
802
783
  this._reportingHeard();
803
784
  const text = report.reply?.trim() || null;
804
- this.reply = text ? { id: ++this._replies, text, at: new Date(reportTime(report.at)).toISOString() } : null;
785
+ this.reply = text ? { id: ++this._replies, text } : null;
805
786
  this._changed();
806
- return this.reply && { id: this.reply.id, at: this.reply.at };
807
- }
808
-
809
- /**
810
- * Claude Code asked to use a tool (request), or a call it asked about ended (done). A done ends only the request
811
- * it follows; an interactive denial reports nothing, and the main agent's next turn report ends the wait.
812
- */
813
- reportPermission(report) {
814
- if (!report || typeof report !== 'object') throw badRequest('permission report must be an object');
815
- if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
816
- if (!PERMISSION_EVENTS.has(report.permission)) throw badRequest(`permission must be one of ${[...PERMISSION_EVENTS].join(', ')}`);
817
- if (typeof report.match !== 'string' || !/^[a-f0-9]{32}$/.test(report.match)) throw badRequest('match must be a call hash');
818
- this._reportingHeard();
819
- const agent = typeof report.agentId === 'string' ? report.agentId.slice(0, 128) : '';
820
- const key = `${agent}\n${report.match}`;
821
- const at = reportTime(report.at);
822
- if (report.permission === 'done') {
823
- remember(this._permissionClears, key, at);
824
- if (this._permissions.get(key) <= at) this._dropPermissions([key], at);
825
- } else {
826
- const answered = Math.max(this._permissionClears.get(key) ?? -Infinity, this._agentPermissionClears.get(agent) ?? -Infinity);
827
- if (at > answered && !(this._permissions.get(key) >= at)) {
828
- this._permissions.set(key, at);
829
- this._changed();
830
- }
831
- }
832
- return this._permissionJSON();
833
- }
834
-
835
- /** The main agent's turn: a prompt was submitted (working), or the turn ended, with or without a reply (ended). */
836
- reportTurn(report) {
837
- if (!report || typeof report !== 'object') throw badRequest('turn report must be an object');
838
- if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
839
- if (!TURN_STATES.has(report.turn)) throw badRequest(`turn must be one of ${[...TURN_STATES].join(', ')}`);
840
- this._reportingHeard();
841
- const at = reportTime(report.at);
842
- this._clearAgentPermissions('', at);
843
- if (!this.turn || this.turn.at <= at) {
844
- this.turn = { state: report.turn, at };
845
- this._changed();
846
- }
847
- return this._turnJSON();
848
- }
849
-
850
- /** An agent's turn or the agent itself ended: every request it made before then was answered. */
851
- _clearAgentPermissions(agent, at) {
852
- remember(this._agentPermissionClears, agent, at);
853
- const prefix = `${agent}\n`;
854
- this._dropPermissions([...this._permissions].filter(([key, since]) => key.startsWith(prefix) && since <= at).map(([key]) => key), at);
855
- }
856
-
857
- _dropPermissions(keys, at) {
858
- if (!keys.length) return;
859
- for (const key of keys) this._permissions.delete(key);
860
- if (this._permissions.size === 0) this._permissionChangedAt = at;
861
- this._changed();
862
- }
863
-
864
- _permissionJSON() {
865
- if (!PERMISSION_REPORTING.has(this.provider.reporting) || !this._hooksSupplied || this.status !== 'running' || this.reporting?.state !== 'active') return null;
866
- if (this._permissions.size) return { state: 'waiting', since: new Date(Math.min(...this._permissions.values())).toISOString() };
867
- return { state: 'none', since: new Date(this._permissionChangedAt ?? this._reportingActiveAt ?? Date.parse(this.startedAt)).toISOString() };
868
- }
869
-
870
- _turnJSON() {
871
- if (!TURN_REPORTING.has(this.provider.reporting) || this.status !== 'running' || this.reporting?.state !== 'active' || !this.turn) return null;
872
- return { state: this.turn.state, at: new Date(this.turn.at).toISOString() };
787
+ return this.reply && { id: this.reply.id };
873
788
  }
874
789
 
875
790
  reportHello() {
@@ -882,7 +797,6 @@ export class Session extends EventEmitter {
882
797
  clearTimeout(this._reportingTimer);
883
798
  if (!this.reporting || this.reporting.state === 'active') return;
884
799
  this.reporting = { state: 'active', reason: null };
885
- this._reportingActiveAt = Date.now();
886
800
  this._changed();
887
801
  }
888
802
 
@@ -950,9 +864,7 @@ export class Session extends EventEmitter {
950
864
  model: this.model,
951
865
  toolSessionId: this.toolSessionId,
952
866
  context: { ...this.context },
953
- reply: this.reply && { id: this.reply.id, at: this.reply.at },
954
- permission: this._permissionJSON(),
955
- turn: this._turnJSON(),
867
+ reply: this.reply && { id: this.reply.id },
956
868
  reporting: this.reporting,
957
869
  agents: [...this.agents.values()],
958
870
  shells: [...this.shells.values()].filter((s) => s.visible).map((s) => ({ id: s.id, kind: s.kind })),
@@ -960,20 +872,6 @@ export class Session extends EventEmitter {
960
872
  }
961
873
  }
962
874
 
963
- /** A report's time: when its hook started, in milliseconds, or now for one that has none or names a later time. */
964
- function reportTime(at) {
965
- const now = Date.now();
966
- return Number.isFinite(at) && at > 0 && at < now ? at : now;
967
- }
968
-
969
- /** Record a time under a key, keeping the latest and only the most recent keys. */
970
- function remember(map, key, at) {
971
- const last = map.get(key) ?? -Infinity;
972
- map.delete(key);
973
- map.set(key, Math.max(last, at));
974
- if (map.size > MAX_PERMISSION_CLEARS) map.delete(map.keys().next().value);
975
- }
976
-
977
875
  function count(value) {
978
876
  return Number.isSafeInteger(value) && value > 0 ? value : 0;
979
877
  }
@@ -39,10 +39,6 @@ const TOOL_START_EVENTS = new Set(['PreToolUse']);
39
39
  const TOOL_END_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
40
40
  const TURN_BOUNDARY_EVENTS = new Set(['UserPromptSubmit', 'Stop', 'StopFailure', 'Interrupt']);
41
41
  const PERMISSION_EVENTS = new Set(['PermissionRequest', 'PermissionDenied']);
42
- // A call Claude Code asked permission for ends with one of these; an interactive denial fires none of them.
43
- const PERMISSION_ENDS = new Set(['PostToolUse', 'PostToolUseFailure', 'PermissionDenied']);
44
- // What the main agent's own events say about its turn.
45
- const TURN_EVENTS = new Map([['UserPromptSubmit', 'working'], ['Stop', 'ended'], ['StopFailure', 'ended'], ['Interrupt', 'ended']]);
46
42
  const MAX_DETAIL = 200;
47
43
 
48
44
  const FINISHED_TASKS = new Set(['completed', 'failed', 'killed']);
@@ -57,14 +53,6 @@ export function commandHash(command) {
57
53
  return crypto.createHash('sha256').update(command.replace(/\s+/g, ' ').trim()).digest('hex').slice(0, 32);
58
54
  }
59
55
 
60
- const sortedJson = (value) => JSON.stringify(value, (_key, v) => (v && typeof v === 'object' && !Array.isArray(v)
61
- ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v));
62
-
63
- /** A tool call by its name and input: Claude Code's PermissionRequest carries no call id. */
64
- export function callHash(toolName, toolInput) {
65
- return crypto.createHash('sha256').update(`${toolName}\n${sortedJson(toolInput ?? null)}`).digest('hex').slice(0, 32);
66
- }
67
-
68
56
  function shellReport(event, input, toolName, subagentId) {
69
57
  const toolInput = input.tool_input || input.toolInput || {};
70
58
  const id = text(input.tool_use_id, input.toolUseId);
@@ -193,7 +181,6 @@ export function hookToReports(input) {
193
181
  reports.push({ shell: 'end', task: taskId.slice(0, 128), ...(toolUseId ? { key: toolUseId.slice(0, 128) } : {}) });
194
182
  if (FINISHED_TASKS.has(status)) reports.push({ agentId: `hook-${taskId}`, status: 'done' });
195
183
  }
196
- if (!subagentId && !text(input.subagent_type, input.subagentType) && !taskNotifications(input.prompt)) reports.push({ turn: 'working' });
197
184
  return reports;
198
185
  }
199
186
 
@@ -242,16 +229,6 @@ export function hookToReports(input) {
242
229
  }
243
230
  }
244
231
 
245
- // Claude Code only (turn_id is Codex's): Codex CLI runs PermissionRequest before its Guardian reviewer as well, so
246
- // there it does not mean the user is being asked.
247
- if (toolName && !text(input.turn_id) && (event === 'PermissionRequest' || PERMISSION_ENDS.has(event))) {
248
- reports.push({
249
- permission: event === 'PermissionRequest' ? 'request' : 'done',
250
- match: callHash(toolName, input.tool_input ?? input.toolInput),
251
- ...(subagentId ? { agentId: `hook-${subagentId}` } : {}),
252
- });
253
- }
254
-
255
232
  // Codex CLI: SubagentStop ended the previous turn of this agent; a new
256
233
  // prompt inside it, or a tool call of a turn inside it (turn_id is Codex's
257
234
  // own field, so Claude Code's internal helpers stay out), means the agent
@@ -272,7 +249,6 @@ export function hookToReports(input) {
272
249
  const reply = event !== 'Stop' || insideSubagent ? undefined
273
250
  : 'last_assistant_message' in input ? input.last_assistant_message : input.lastAssistantMessage;
274
251
  if (reply !== undefined) reports.push({ reply: typeof reply === 'string' ? reply : null });
275
- if (!insideSubagent && TURN_EVENTS.has(event)) reports.push({ turn: TURN_EVENTS.get(event) });
276
252
 
277
253
  const model = insideSubagent || toolEvent || PERMISSION_EVENTS.has(event) ? null : event === 'PostModelSwitch'
278
254
  ? text(input.to_model)
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>