agents-dojo 0.1.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 ADDED
@@ -0,0 +1,47 @@
1
+ # AgentsDojo
2
+
3
+ A Claude Code SDK-based Agent framework with A2A protocol support and a pixel-art Monitor GUI.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npm install -g agents-dojo
9
+
10
+ # Create a project with a sample agent
11
+ mkdir my-project && cd my-project
12
+ agents-dojo init
13
+
14
+ # Start the server
15
+ agents-dojo
16
+ ```
17
+
18
+ To add your own agent, create a directory under `agents/` with two files:
19
+
20
+ - `manifest.jsonc` — agent identity, model, A2A card
21
+ - `context.md` — system prompt (role, rules, output format)
22
+
23
+ See `agents/_template_echo/` for a complete manifest reference.
24
+
25
+ ## Monitor GUI
26
+
27
+ ```bash
28
+ agents-dojo --monitor-port=41242
29
+ cd monitor && npm install && npm run dev
30
+ # Open http://localhost:5173
31
+ ```
32
+
33
+ ## Architecture
34
+
35
+ - Framework (`src/`): A2A server, Claude Code SDK bridge, monitor event bus
36
+ - Monitor (`monitor/`): React + Pixi.js GUI
37
+
38
+ ## Testing
39
+
40
+ ```bash
41
+ npm test # unit + component + integration
42
+ npm run test:e2e # E2E (requires ANTHROPIC_API_KEY)
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,14 @@
1
+ import { type Express } from 'express';
2
+ import type { AgentRegistry } from './agent-registry.js';
3
+ import type { MonitorBus } from './monitor-bus.js';
4
+ export interface A2AServerOptions {
5
+ registry: AgentRegistry;
6
+ singleAgent?: string;
7
+ port?: number;
8
+ monitorBus?: MonitorBus;
9
+ }
10
+ export interface A2AServerHandle {
11
+ app: Express;
12
+ close: () => Promise<void>;
13
+ }
14
+ export declare function createA2AServer(opts: A2AServerOptions): A2AServerHandle;
@@ -0,0 +1,44 @@
1
+ // src/a2a-server.ts
2
+ import express from 'express';
3
+ import { DefaultRequestHandler, InMemoryTaskStore, } from '@a2a-js/sdk/server';
4
+ import { agentCardHandler, jsonRpcHandler, UserBuilder, } from '@a2a-js/sdk/server/express';
5
+ import { AGENT_CARD_PATH } from '@a2a-js/sdk';
6
+ import { DojoAgentExecutor } from './agent-executor.js';
7
+ export function createA2AServer(opts) {
8
+ const app = express();
9
+ app.use(express.json());
10
+ const agents = opts.singleAgent
11
+ ? [opts.registry.get(opts.singleAgent)].filter((a) => a !== undefined)
12
+ : opts.registry.list().map((id) => opts.registry.get(id)).filter((a) => a !== undefined);
13
+ for (const loaded of agents) {
14
+ const card = {
15
+ name: loaded.manifest.name,
16
+ description: loaded.manifest.description,
17
+ url: `http://localhost:${opts.port ?? 41241}/a2a/${loaded.manifest.id}`,
18
+ version: loaded.manifest.version,
19
+ protocolVersion: '1.0',
20
+ capabilities: {
21
+ streaming: true,
22
+ pushNotifications: false,
23
+ stateTransitionHistory: true,
24
+ },
25
+ defaultInputModes: ['text'],
26
+ defaultOutputModes: ['text'],
27
+ skills: (loaded.manifest.a2aCard?.skills ?? []),
28
+ };
29
+ const taskStore = new InMemoryTaskStore();
30
+ const executor = new DojoAgentExecutor(loaded, { monitorBus: opts.monitorBus });
31
+ const requestHandler = new DefaultRequestHandler(card, taskStore, executor);
32
+ const userBuilder = UserBuilder.noAuthentication;
33
+ app.use(`/a2a/${loaded.manifest.id}`, jsonRpcHandler({ requestHandler, userBuilder }));
34
+ app.use(`/a2a/${loaded.manifest.id}${AGENT_CARD_PATH}`, agentCardHandler({ agentCardProvider: requestHandler }));
35
+ app.use(`/a2a/${loaded.manifest.id}/card`, agentCardHandler({ agentCardProvider: requestHandler }));
36
+ }
37
+ return {
38
+ app,
39
+ close: () => new Promise((resolve) => {
40
+ // No actual server here; createServer() handles listening
41
+ resolve();
42
+ }),
43
+ };
44
+ }
@@ -0,0 +1,15 @@
1
+ import type { AgentExecutor, RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
2
+ import type { MonitorBus } from './monitor-bus.js';
3
+ import type { LoadedAgent } from './types.js';
4
+ export interface AgentExecutorOptions {
5
+ monitorBus?: MonitorBus;
6
+ }
7
+ export declare class DojoAgentExecutor implements AgentExecutor {
8
+ private agent;
9
+ private options;
10
+ private controllers;
11
+ private contextIds;
12
+ constructor(agent: LoadedAgent, options?: AgentExecutorOptions);
13
+ cancelTask: (taskId: string, eventBus: ExecutionEventBus) => Promise<void>;
14
+ execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void>;
15
+ }
@@ -0,0 +1,147 @@
1
+ // src/agent-executor.ts
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { a2aToContentBlocks } from './part-mapper.js';
4
+ import { runClaude } from './claude-bridge.js';
5
+ import { createTranslator } from './event-translator.js';
6
+ import { recordTaskStart, recordTaskEnd } from './metrics.js';
7
+ export class DojoAgentExecutor {
8
+ agent;
9
+ options;
10
+ controllers = new Map();
11
+ contextIds = new Map();
12
+ constructor(agent, options = {}) {
13
+ this.agent = agent;
14
+ this.options = options;
15
+ }
16
+ cancelTask = async (taskId, eventBus) => {
17
+ const controller = this.controllers.get(taskId);
18
+ if (!controller)
19
+ return;
20
+ const contextId = this.contextIds.get(taskId);
21
+ controller.abort();
22
+ this.controllers.delete(taskId);
23
+ this.contextIds.delete(taskId);
24
+ // Publish canceled state to the bus so the client sees the transition.
25
+ // Guard against publishing if the bus is no longer accepting (best-effort).
26
+ try {
27
+ eventBus.publish({
28
+ kind: 'status-update',
29
+ taskId,
30
+ contextId,
31
+ status: {
32
+ state: 'canceled',
33
+ timestamp: new Date().toISOString(),
34
+ },
35
+ final: true,
36
+ });
37
+ eventBus.finished();
38
+ }
39
+ catch {
40
+ // ignore — the SDK subprocess may already be torn down
41
+ }
42
+ };
43
+ async execute(requestContext, eventBus) {
44
+ const { taskId, contextId, userMessage } = requestContext;
45
+ recordTaskStart();
46
+ // Publish the initial task snapshot. The A2A SDK's ResultManager only
47
+ // initializes its currentTask from a `kind: 'task'` event; without it,
48
+ // subsequent status-update / artifact-update events are dropped as
49
+ // "unknown task" and the SDK reports "no task context found".
50
+ eventBus.publish({
51
+ kind: 'task',
52
+ id: taskId,
53
+ contextId,
54
+ status: {
55
+ state: 'submitted',
56
+ timestamp: new Date().toISOString(),
57
+ },
58
+ history: [userMessage],
59
+ artifacts: [],
60
+ });
61
+ // Emit task_created to monitor bus. A2A has no real "sender" concept beyond
62
+ // user/agent, so we use the convention from: 'user' for any inbound client.
63
+ this.options.monitorBus?.emit({
64
+ type: 'task_created',
65
+ taskId,
66
+ contextId,
67
+ from: 'user',
68
+ to: this.agent.manifest.id,
69
+ preview: extractPreview(userMessage.parts),
70
+ });
71
+ // 1. Extract text content from A2A message
72
+ const parts = userMessage.parts;
73
+ const contentBlocks = a2aToContentBlocks(parts);
74
+ // 2. Set up event translator
75
+ const translator = createTranslator({
76
+ agentId: this.agent.manifest.id,
77
+ taskId,
78
+ contextId,
79
+ bus: eventBus,
80
+ monitorBus: this.options.monitorBus,
81
+ });
82
+ // 3. Wire up cancellation: each task gets its own AbortController. We pass
83
+ // it to the SDK so abort() can tear down the subprocess, and we keep a
84
+ // reference so cancelTask() can find it by taskId.
85
+ const controller = new AbortController();
86
+ this.controllers.set(taskId, controller);
87
+ this.contextIds.set(taskId, contextId);
88
+ // 4. Run Claude (iterate events)
89
+ try {
90
+ for await (const sdkMsg of runClaude({
91
+ agent: this.agent,
92
+ contentBlocks,
93
+ contextId,
94
+ onEvent: (m) => translator.onSdkEvent(m),
95
+ abortController: controller,
96
+ })) {
97
+ // events are published via onEvent
98
+ }
99
+ recordTaskEnd(true);
100
+ }
101
+ catch (err) {
102
+ recordTaskEnd(false);
103
+ // If the controller was aborted, the SDK throws an AbortError. We don't
104
+ // want to report it as a generic failure — cancelTask already published
105
+ // the canceled state. Suppress the publish in that case.
106
+ const isAbort = controller.signal.aborted || (err instanceof Error && /abort/i.test(err.message));
107
+ if (!isAbort) {
108
+ // SDK subprocess crash, network failure, etc.
109
+ const reason = err instanceof Error ? err.message : String(err);
110
+ eventBus.publish({
111
+ kind: 'status-update',
112
+ taskId,
113
+ contextId,
114
+ status: {
115
+ state: 'failed',
116
+ message: {
117
+ kind: 'message',
118
+ role: 'agent',
119
+ messageId: uuidv4(),
120
+ parts: [{ kind: 'text', text: `Subprocess error: ${reason}` }],
121
+ taskId,
122
+ contextId,
123
+ },
124
+ timestamp: new Date().toISOString(),
125
+ },
126
+ final: true,
127
+ });
128
+ eventBus.finished();
129
+ }
130
+ }
131
+ finally {
132
+ this.controllers.delete(taskId);
133
+ this.contextIds.delete(taskId);
134
+ }
135
+ }
136
+ }
137
+ function extractPreview(parts) {
138
+ if (!Array.isArray(parts))
139
+ return '';
140
+ for (const p of parts) {
141
+ if (p && typeof p === 'object' && p.kind === 'text' && typeof p.text === 'string') {
142
+ const t = p.text;
143
+ return t.length > 80 ? t.slice(0, 80) + '…' : t;
144
+ }
145
+ }
146
+ return '';
147
+ }
@@ -0,0 +1,8 @@
1
+ import type { LoadedAgent } from './types.js';
2
+ export declare class AgentLoadError extends Error {
3
+ agentDir: string;
4
+ cause?: unknown | undefined;
5
+ constructor(agentDir: string, message: string, cause?: unknown | undefined);
6
+ }
7
+ export declare function loadAgent(agentDir: string): LoadedAgent;
8
+ export declare function loadAgents(agentsDir: string): Map<string, LoadedAgent>;
@@ -0,0 +1,89 @@
1
+ // src/agent-loader.ts
2
+ import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
3
+ import { join, resolve, isAbsolute } from 'path';
4
+ import stripJsonComments from 'strip-json-comments';
5
+ import { AgentManifestSchema } from './manifest-schema.js';
6
+ import { loadContext } from './context-manager.js';
7
+ export class AgentLoadError extends Error {
8
+ agentDir;
9
+ cause;
10
+ constructor(agentDir, message, cause) {
11
+ super(`[${agentDir}] ${message}`);
12
+ this.agentDir = agentDir;
13
+ this.cause = cause;
14
+ this.name = 'AgentLoadError';
15
+ }
16
+ }
17
+ /** Resolve the manifest file path. Prefers `.jsonc`, falls back to `.json`. */
18
+ function resolveManifestPath(agentDir) {
19
+ const jsonc = join(agentDir, 'manifest.jsonc');
20
+ if (existsSync(jsonc))
21
+ return jsonc;
22
+ const json = join(agentDir, 'manifest.json');
23
+ if (existsSync(json))
24
+ return json;
25
+ return undefined;
26
+ }
27
+ function loadManifest(agentDir) {
28
+ const path = resolveManifestPath(agentDir);
29
+ if (!path) {
30
+ throw new AgentLoadError(agentDir, 'manifest.jsonc (or manifest.json) not found');
31
+ }
32
+ const raw = JSON.parse(stripJsonComments(readFileSync(path, 'utf-8')));
33
+ const result = AgentManifestSchema.safeParse(raw);
34
+ if (!result.success) {
35
+ const issues = result.error.issues.map(i => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
36
+ throw new AgentLoadError(agentDir, `invalid manifest:\n${issues}`);
37
+ }
38
+ return result.data;
39
+ }
40
+ function resolveRef(agentDir, relPath) {
41
+ if (!relPath)
42
+ return undefined;
43
+ const abs = isAbsolute(relPath) ? relPath : join(agentDir, relPath);
44
+ if (!existsSync(abs)) {
45
+ throw new AgentLoadError(agentDir, `referenced file not found: ${relPath} (resolved to ${abs})`);
46
+ }
47
+ return abs;
48
+ }
49
+ export function loadAgent(agentDir) {
50
+ const manifest = loadManifest(agentDir);
51
+ const fixedContextContent = loadContext(agentDir, manifest.fixedContext);
52
+ return {
53
+ manifest,
54
+ agentDir,
55
+ fixedContextContent,
56
+ mcpServersPath: resolveRef(agentDir, manifest.mcpServers),
57
+ hooksPath: resolveRef(agentDir, manifest.hooks),
58
+ settingsPath: resolveRef(agentDir, manifest.settings),
59
+ agentsPath: resolveRef(agentDir, manifest.agents),
60
+ sandboxPath: resolveRef(agentDir, manifest.sandbox),
61
+ };
62
+ }
63
+ export function loadAgents(agentsDir) {
64
+ const result = new Map();
65
+ const absDir = resolve(agentsDir);
66
+ if (!existsSync(absDir))
67
+ return result;
68
+ for (const name of readdirSync(absDir)) {
69
+ // skip templates and scaffolds: underscore-prefixed directories are not loaded as agents
70
+ if (name.startsWith('_'))
71
+ continue;
72
+ const path = join(absDir, name);
73
+ if (!statSync(path).isDirectory())
74
+ continue;
75
+ if (!resolveManifestPath(path))
76
+ continue;
77
+ try {
78
+ const agent = loadAgent(path);
79
+ if (result.has(agent.manifest.id)) {
80
+ throw new Error(`Duplicate agent id "${agent.manifest.id}"`);
81
+ }
82
+ result.set(agent.manifest.id, agent);
83
+ }
84
+ catch (err) {
85
+ console.error(`[agent-loader] Failed to load ${name}:`, err instanceof Error ? err.message : err);
86
+ }
87
+ }
88
+ return result;
89
+ }
@@ -0,0 +1,20 @@
1
+ import { EventEmitter } from 'events';
2
+ import type { LoadedAgent } from './types.js';
3
+ export type ReloadErrorCode = 'not_found' | 'load_error';
4
+ export type ReloadResult = {
5
+ ok: true;
6
+ agent: LoadedAgent;
7
+ } | {
8
+ ok: false;
9
+ code: ReloadErrorCode;
10
+ message: string;
11
+ };
12
+ export declare class AgentRegistry extends EventEmitter {
13
+ private agentsDir;
14
+ private agents;
15
+ constructor(agentsDir: string);
16
+ load(): void;
17
+ list(): string[];
18
+ get(id: string): LoadedAgent | undefined;
19
+ reload(id: string): ReloadResult;
20
+ }
@@ -0,0 +1,39 @@
1
+ // src/agent-registry.ts
2
+ import { EventEmitter } from 'events';
3
+ import { loadAgent, loadAgents } from './agent-loader.js';
4
+ export class AgentRegistry extends EventEmitter {
5
+ agentsDir;
6
+ agents = new Map();
7
+ constructor(agentsDir) {
8
+ super();
9
+ this.agentsDir = agentsDir;
10
+ }
11
+ load() {
12
+ this.agents = loadAgents(this.agentsDir);
13
+ for (const [id, agent] of this.agents) {
14
+ this.emit('agent_loaded', { agentId: id, position: agent.manifest.monitor?.position });
15
+ }
16
+ }
17
+ list() {
18
+ return Array.from(this.agents.keys()).sort();
19
+ }
20
+ get(id) {
21
+ return this.agents.get(id);
22
+ }
23
+ reload(id) {
24
+ const existing = this.agents.get(id);
25
+ if (!existing) {
26
+ return { ok: false, code: 'not_found', message: `Agent "${id}" not found` };
27
+ }
28
+ try {
29
+ const updated = loadAgent(existing.agentDir);
30
+ this.agents.set(id, updated);
31
+ this.emit('agent_reloaded', { agentId: id });
32
+ return { ok: true, agent: updated };
33
+ }
34
+ catch (err) {
35
+ const msg = err instanceof Error ? err.message : String(err);
36
+ return { ok: false, code: 'load_error', message: msg };
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,11 @@
1
+ import { type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { LoadedAgent } from './types.js';
3
+ import type { AnthropicContentBlock } from './part-mapper.js';
4
+ export interface RunClaudeParams {
5
+ agent: LoadedAgent;
6
+ contentBlocks: AnthropicContentBlock[];
7
+ contextId: string;
8
+ onEvent: (event: SDKMessage) => void;
9
+ abortController?: AbortController;
10
+ }
11
+ export declare function runClaude(params: RunClaudeParams): AsyncGenerator<SDKMessage>;
@@ -0,0 +1,124 @@
1
+ // src/claude-bridge.ts
2
+ import { join } from 'path';
3
+ import { query } from '@anthropic-ai/claude-agent-sdk';
4
+ export async function* runClaude(params) {
5
+ const { agent, contentBlocks, onEvent, abortController } = params;
6
+ const m = agent.manifest;
7
+ const systemPrompt = m.systemPromptAppend
8
+ ? `${agent.fixedContextContent}\n\n${m.systemPromptAppend}`
9
+ : agent.fixedContextContent;
10
+ const env = {
11
+ ...(m.env ?? {}),
12
+ };
13
+ // Only override CLAUDE_CONFIG_DIR if the user explicitly specified configDir.
14
+ // Otherwise, let the subprocess inherit the parent's config (preserving auth).
15
+ if (m.configDir) {
16
+ env.CLAUDE_CONFIG_DIR = m.configDir.startsWith('/')
17
+ ? m.configDir
18
+ : join(agent.agentDir, m.configDir);
19
+ }
20
+ // Build options. Note: we do NOT pass `resume: contextId` here — the Claude
21
+ // SDK treats resume as a previously-issued session ID, and A2A's contextId
22
+ // is a fresh UUID with no corresponding SDK session. Treating every send
23
+ // as a brand-new Claude session is the safe default. (Future: persist a
24
+ // contextId→sessionId mapping if cross-call continuity is required.)
25
+ const options = {
26
+ systemPrompt,
27
+ cwd: agent.agentDir,
28
+ env,
29
+ };
30
+ if (abortController) {
31
+ options.abortController = abortController;
32
+ }
33
+ if (m.model)
34
+ options.model = m.model;
35
+ if (m.fallbackModel)
36
+ options.fallbackModel = m.fallbackModel;
37
+ if (m.executable)
38
+ options.executable = m.executable;
39
+ if (m.executableArgs)
40
+ options.executableArgs = m.executableArgs;
41
+ if (m.pathToClaudeCodeExecutable)
42
+ options.pathToClaudeCodeExecutable = m.pathToClaudeCodeExecutable;
43
+ if (m.extraArgs)
44
+ options.extraArgs = m.extraArgs;
45
+ if (m.tools)
46
+ options.tools = m.tools;
47
+ if (m.allowedTools)
48
+ options.allowedTools = m.allowedTools;
49
+ if (m.disallowedTools)
50
+ options.disallowedTools = m.disallowedTools;
51
+ if (m.toolAliases)
52
+ options.toolAliases = m.toolAliases;
53
+ if (m.permissionMode)
54
+ options.permissionMode = m.permissionMode;
55
+ if (m.planModeInstructions)
56
+ options.planModeInstructions = m.planModeInstructions;
57
+ if (m.allowDangerouslySkipPermissions !== undefined)
58
+ options.allowDangerouslySkipPermissions = m.allowDangerouslySkipPermissions;
59
+ if (m.permissionPromptToolName)
60
+ options.permissionPromptToolName = m.permissionPromptToolName;
61
+ if (m.strictMcpConfig !== undefined)
62
+ options.strictMcpConfig = m.strictMcpConfig;
63
+ if (m.additionalDirectories)
64
+ options.additionalDirectories = m.additionalDirectories;
65
+ if (m.includeHookEvents !== undefined)
66
+ options.includeHookEvents = m.includeHookEvents;
67
+ if (m.skills)
68
+ options.skills = m.skills;
69
+ if (m.settingSources)
70
+ options.settingSources = m.settingSources;
71
+ if (m.thinking)
72
+ options.thinking = m.thinking;
73
+ if (m.effort)
74
+ options.effort = m.effort;
75
+ if (m.maxThinkingTokens !== undefined)
76
+ options.maxThinkingTokens = m.maxThinkingTokens;
77
+ if (m.maxTurns !== undefined)
78
+ options.maxTurns = m.maxTurns;
79
+ if (m.maxBudgetUsd !== undefined)
80
+ options.maxBudgetUsd = m.maxBudgetUsd;
81
+ if (m.taskBudget)
82
+ options.taskBudget = m.taskBudget;
83
+ if (m.betas)
84
+ options.betas = m.betas;
85
+ if (m.outputFormat)
86
+ options.outputFormat = m.outputFormat;
87
+ if (m.forkSession !== undefined)
88
+ options.forkSession = m.forkSession;
89
+ if (agent.agentsPath)
90
+ options.agents = agent.agentsPath;
91
+ if (m.agent)
92
+ options.agent = m.agent;
93
+ if (m.agentProgressSummaries !== undefined)
94
+ options.agentProgressSummaries = m.agentProgressSummaries;
95
+ if (m.forwardSubagentText !== undefined)
96
+ options.forwardSubagentText = m.forwardSubagentText;
97
+ if (m.plugins)
98
+ options.plugins = m.plugins;
99
+ if (agent.settingsPath)
100
+ options.settings = agent.settingsPath;
101
+ // Load MCP servers from file if path is set
102
+ if (agent.mcpServersPath) {
103
+ const { readFileSync } = await import('fs');
104
+ options.mcpServers = JSON.parse(readFileSync(agent.mcpServersPath, 'utf-8'));
105
+ }
106
+ // Load hooks from file if path is set
107
+ if (agent.hooksPath) {
108
+ const { readFileSync } = await import('fs');
109
+ options.hooks = JSON.parse(readFileSync(agent.hooksPath, 'utf-8'));
110
+ }
111
+ // Build async iterable prompt
112
+ async function* promptIterable() {
113
+ yield {
114
+ type: 'user',
115
+ message: { role: 'user', content: contentBlocks },
116
+ parent_tool_use_id: null,
117
+ };
118
+ }
119
+ const q = query({ prompt: promptIterable(), options });
120
+ for await (const msg of q) {
121
+ onEvent(msg);
122
+ yield msg;
123
+ }
124
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ interface Args {
3
+ agentsDir: string;
4
+ port: number;
5
+ monitorPort: number | null;
6
+ singleAgent: string | null;
7
+ help: boolean;
8
+ }
9
+ export declare const DEFAULT_ARGS: Args;
10
+ export {};