@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.
- package/bin/agent-guild-report.mjs +0 -5
- package/docs/api.md +850 -0
- package/examples/claude-code-settings.json +4 -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 +5 -3
- package/src/manager/session-hooks.mjs +3 -4
- package/src/manager/session-manager.mjs +0 -8
- package/src/manager/session.mjs +4 -106
- package/src/report/hooks.mjs +0 -24
- 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
|
@@ -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",
|
|
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.14.
|
|
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(/^\/+/, '');
|
|
@@ -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
|
|
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
|
|
20
|
-
//
|
|
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.
|
package/src/manager/session.mjs
CHANGED
|
@@ -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
|
|
785
|
+
this.reply = text ? { id: ++this._replies, text } : null;
|
|
805
786
|
this._changed();
|
|
806
|
-
return this.reply && { id: this.reply.id
|
|
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
|
|
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
|
}
|
package/src/report/hooks.mjs
CHANGED
|
@@ -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>
|