Skip to content

Commit 98a2520

Browse files
committed
add agents
1 parent f8396de commit 98a2520

37 files changed

Lines changed: 6280 additions & 635 deletions

.claude/agents/api-agent.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ You accomplish tasks by calling the local API at `http://localhost:3000`.
44

55
See also: [Backend API Guidelines](/BACKEND_GUIDELINES.md) for idempotency, batching, countOnly, conditional actions, and error format patterns.
66

7+
## Scope
8+
9+
Use this agent only when the request requires reading or mutating workspace/application data through API endpoints.
10+
11+
Do not use this agent for general coding tasks that do not depend on API data operations.
12+
713
## Auth
814

915
Before making any API calls, check if a stored API key exists at `.claude-api-key` in the project root. Read that file first.
@@ -25,7 +31,7 @@ Once the user provides the key, save it to `.claude-api-key` in the project root
2531

2632
## Flows
2733

28-
Before acting on a request, check `docs/flows/` for a matching workflow and follow it.
34+
Before acting on an API data request, check `docs/flows/` for a matching workflow and follow it.
2935

3036
## How you work
3137

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
Use `/Users/vladislav/Documents/projects/amo/replace_prototype/.claude/agents/api-agent.md` to manipulate data.
1+
Use `/Users/vladislav/Documents/projects/amo/replace_prototype/.claude/agents/api-agent.md` only when a request needs workspace/app data manipulation through the backend API (for example: create/update/delete entities, message drafts, conversation/task/card/company data operations, batch API jobs).
2+
3+
For general software tasks that do not require API data manipulation (for example: refactors, tests, docs, build tooling, UI changes, local file edits), do not invoke the API agent.

TOFIX.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# TOFIX
2+
3+
## Critical
4+
5+
- **Plaintext workspace API keys** (`services/agents.ts:219`) — `wsKey.rawKey` stored in database without hashing, unlike regular API keys which use `keyHash`. A compromised DB dump exposes all agent workspace credentials.
6+
- **Workspace API key exposed via agent endpoints** (`services/agents.ts:156-160`, `routes/agents.ts:72-76`, `routes/agents.ts:154-158`) — `asAgent()` returns all fields including `workspaceApiKey`, and list/get routes return this directly. This leaks a write-capable internal key to any user with `settings:read` permission.
7+
- **HTML sanitization via regex** (`frontend/src/pages/inbox/InboxPage.tsx:254`, `renderFormattedContent()`) — regex-based HTML filtering with user-controlled `<a href>` is an XSS vector. Use DOMPurify or a proper HTML sanitization library.
8+
9+
## High
10+
11+
- **No rollback on agent creation** (`services/agents.ts:193-246`) — if preset rendering or workspace file creation fails mid-way, partial agent record and API key persist. Validate everything and prepare all data before inserting into store.
12+
- **Backend build can ship stale presets after first build** (`packages/backend/package.json:8`, `services/agents.ts:41-51`) — `cp -r src/presets dist/presets` nests into existing dir on repeated builds (`dist/presets/presets/*`), while loader reads only `dist/presets/*`. Changed presets may not be picked up unless `dist` is cleaned. Use `rm -rf dist/presets && cp -r src/presets dist/presets`.
13+
- **FilePreviewModal race condition** (`frontend/src/components/FilePreviewModal.tsx`) — rapidly opening/closing spawns concurrent fetches without aborting previous ones. Add `AbortController` and cancel pending requests on unmount or when source changes.
14+
- **Memory leak in agent-chat-runtime** (`frontend/src/stores/agent-chat-runtime.ts`) — `streamsById` Map has no max size limit; 2-minute retention may accumulate entries under heavy use. Add a max cap (e.g., 100 streams) and evict oldest when exceeded.
15+
- **Agent deletion misses legacy conversations** (`services/agents.ts:276-283`) — cleanup only targets `channelType === 'agent'`; legacy rows with `channelType: 'other'` + `metadata.agentId` remain orphaned. Update query to also match legacy pattern.
16+
- **Agent conversation draft cleanup uses wrong collection key** (`services/agent-chat.ts:174`) — deletes from `message_drafts`, but draft services use `messageDrafts` (camelCase). Related drafts may not be deleted on conversation deletion.
17+
18+
## Medium
19+
20+
- **Overly permissive channelType schema** (`schemas/collections.ts:149`) — `channelType: z.string()` accepts any value. Should be a union: `z.enum(['telegram', 'internal', 'other', 'agent', 'email', 'web_chat'])` or similar constrained type.
21+
- **Unimplemented channel types in routes** (`routes/conversations.ts:19`) — `'email'` and `'web_chat'` added to enum but no corresponding handlers/services exist. Remove until implemented or add stub handling.
22+
- **SSE error handling incomplete** (`services/agent-chat.ts:368-372`) — `child.on('error')` callback writes to SSE stream after `reply.raw.end()` may have been called. Error events during startup may not reach client.
23+
- **No rate limiting on prompt execution** (`routes/agent-chat.ts:215-221`) — 50KB prompts can spawn processes without concurrency controls per agent or per user. Add rate limiting middleware.
24+
- **Missing system contact validation** (`services/agent-chat.ts:141`) — `contactId: 'system'` assumed to exist without validation. Add check or create system contact on first use.
25+
- **Duplicated utilities**`formatFileSize()` / `formatBytes()` implemented in 3 places (`frontend/src/lib/file-utils.ts`, `InboxPage.tsx`, `BackupsTab.tsx`). Consolidate into shared utility module.
26+
- **Inconsistent API response shapes** — list endpoints return varying shapes: `{entries, total, limit, offset}` vs `{entries}` vs `{clis}`. Standardize on `{entries, total, limit?, offset?}` pattern.
27+
- **Missing tests for agent services** — no unit or integration tests for `services/agents.ts`, `services/agent-chat.ts`, or agent routes. Add test coverage for CRUD, chat streaming, and file operations.
28+
29+
## Low
30+
31+
- **Inconsistent error UX**`CardDetailPage.tsx` uses `alert()` while other pages use inline/toast patterns. Standardize on toast/notification system.
32+
- **Extract shared Modal component** — each page (`AgentsPage`, `ApiKeysTab`, `BackupsTab`, `CardDetailPage`) reimplements overlay/modal patterns independently. Create reusable `Modal` component.
33+
- **Missing memoization in CardDetailPage**`Object.entries()` and `new Set()` created on every render. Wrap in `useMemo()`.
34+
- **StoragePage drag counter**`dragCounter` ref can get stuck if drag events are missed (e.g., user switches tabs mid-drag). Add global `dragleave` on window or use `useEffect` cleanup.
35+
- **No scroll-to-bottom in agent chat** — when new messages stream in, view doesn't auto-scroll. Add `useEffect` to scroll message panel on `text` update.
36+
- **AgentsPage.tsx is ~1800+ lines** — split into sub-components: `AgentListSidebar`, `ChatPanel`, `FileExplorer`, `CreateAgentModal`.
37+
- **Broken path in api-agent.md** (`.claude/agents/api-agent.md:4`) — references `/BACKEND_GUIDELINES.md` but actual file is `docs/backend-api-design-guidelines.md`.
38+
- **Agent avatar picker color presets overflow** — on small screens, the presets grid in `AgentAvatar.tsx` extends beyond modal bounds. Add `max-height` with scroll.
39+
40+
## Fixed
41+
42+
- ~~**Path traversal in agent file operations**~~ — FIXED: `validateAgentPath()` (line 338-347) now properly resolves and verifies paths stay within workspace root.
43+
- ~~**SSE busy-check ordering**~~ — FIXED: busy check (line 215-221) now occurs before SSE headers are written (line 228-231).

docs/design-system.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ Communicate value visually. Prioritize scannability, clear hierarchy, and real-f
6565
- **Secondary:** White background, 1px border, dark text, 8px radius
6666
- **Ghost:** No background, text only with arrow
6767
- **Link:** Accent color text with underline on hover
68+
- **Icon-only buttons:** Always use the shared custom `Tooltip` component (`ui/Tooltip`) for hover/focus labels. Do not use native `title` tooltips. Keep an `aria-label` on the button for accessibility.
6869

6970
### Cards
7071

packages/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "tsx watch src/index.ts",
8-
"build": "tsc",
8+
"build": "tsc && cp -r src/presets dist/presets",
99
"start": "node dist/index.js",
1010
"lint": "eslint src/",
1111
"typecheck": "tsc --noEmit",

packages/backend/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import { folderRoutes } from './routes/folders.js';
3535
import { cardRoutes } from './routes/cards.js';
3636
import { boardRoutes } from './routes/boards.js';
3737
import { storageRoutes } from './routes/storage.js';
38+
import { agentRoutes } from './routes/agents.js';
39+
import { agentChatRoutes } from './routes/agent-chat.js';
3840

3941
function buildHttpsOptions(): SecureContextOptions | undefined {
4042
if (!env.TLS_CERT_PATH || !env.TLS_KEY_PATH) return undefined;
@@ -94,6 +96,8 @@ export async function buildApp() {
9496
await app.register(cardRoutes);
9597
await app.register(boardRoutes);
9698
await app.register(storageRoutes);
99+
await app.register(agentRoutes);
100+
await app.register(agentChatRoutes);
97101

98102
return app;
99103
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# {{agentName}}
2+
3+
{{description}}
4+
5+
## Instructions
6+
7+
Add agent-specific instructions here.
8+
9+
Default workspace behavior:
10+
- Work in the current folder by default for commands and file operations.
11+
- Use other paths only if the user asks or the task requires it; ask first when avoidable.
12+
13+
For long-running tasks, report progress and final output in agent chat:
14+
- Use `POST $WORKSPACE_API_URL/api/agents/:agentId/chat/messages` with `{"conversationId":"...","content":"...","isFinal":false}`
15+
- Send the final answer through the same endpoint with `isFinal: true`
16+
17+
When you need Workspace app API endpoints or schemas, use the live reference at `$WORKSPACE_API_URL/docs` (OpenAPI JSON: `$WORKSPACE_API_URL/docs/json`).
18+
This API is the Workspace app backend interface for reading and updating workspace data (for example cards, messages, storage, boards, folders, tags, and interacting with other agents).
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"id": "basic",
3+
"name": "Basic Agent",
4+
"description": "A simple agent with CLAUDE.MD and AGENT.MD files",
5+
"files": [
6+
{ "type": "file", "name": "CLAUDE.MD", "template": "CLAUDE.MD.hbs" },
7+
{ "type": "symlink", "name": "AGENT.MD", "target": "CLAUDE.MD" }
8+
]
9+
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import type { FastifyInstance } from 'fastify';
2+
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
3+
import { z } from 'zod/v4';
4+
import { requirePermission } from '../middleware/rbac.js';
5+
import { store } from '../db/index.js';
6+
import { getAgent } from '../services/agents.js';
7+
import {
8+
listAgentConversations,
9+
createAgentConversation,
10+
validateConversationOwnership,
11+
deleteAgentConversation,
12+
renameAgentConversation,
13+
saveAgentConversationMessage,
14+
executePrompt,
15+
isAgentBusy,
16+
} from '../services/agent-chat.js';
17+
18+
export async function agentChatRoutes(app: FastifyInstance) {
19+
const typedApp = app.withTypeProvider<ZodTypeProvider>();
20+
21+
// List conversations for an agent
22+
typedApp.get(
23+
'/api/agents/:id/chat/conversations',
24+
{
25+
onRequest: [app.authenticate, requirePermission('settings:read')],
26+
schema: {
27+
tags: ['Agent Chat'],
28+
summary: 'List chat conversations for an agent',
29+
params: z.object({ id: z.string() }),
30+
querystring: z.object({
31+
limit: z.coerce.number().int().min(1).max(200).default(50),
32+
offset: z.coerce.number().int().min(0).default(0),
33+
}),
34+
},
35+
},
36+
async (request, reply) => {
37+
const agent = getAgent(request.params.id);
38+
if (!agent) return reply.notFound('Agent not found');
39+
40+
const { limit, offset } = request.query;
41+
const result = listAgentConversations(request.params.id, limit, offset);
42+
return reply.send(result);
43+
},
44+
);
45+
46+
// Create a new conversation for an agent
47+
typedApp.post(
48+
'/api/agents/:id/chat/conversations',
49+
{
50+
onRequest: [app.authenticate, requirePermission('settings:update')],
51+
schema: {
52+
tags: ['Agent Chat'],
53+
summary: 'Create a new chat conversation for an agent',
54+
params: z.object({ id: z.string() }),
55+
body: z.object({
56+
subject: z.string().max(200).optional(),
57+
}),
58+
},
59+
},
60+
async (request, reply) => {
61+
const agent = getAgent(request.params.id);
62+
if (!agent) return reply.notFound('Agent not found');
63+
64+
const conv = createAgentConversation(request.params.id, request.body.subject);
65+
return reply.status(201).send(conv);
66+
},
67+
);
68+
69+
// Rename a conversation
70+
typedApp.patch(
71+
'/api/agents/:id/chat/conversations/:conversationId',
72+
{
73+
onRequest: [app.authenticate, requirePermission('settings:update')],
74+
schema: {
75+
tags: ['Agent Chat'],
76+
summary: 'Rename an agent chat conversation',
77+
params: z.object({ id: z.string(), conversationId: z.string() }),
78+
body: z.object({
79+
subject: z.string().min(1).max(200),
80+
}),
81+
},
82+
},
83+
async (request, reply) => {
84+
const agent = getAgent(request.params.id);
85+
if (!agent) return reply.notFound('Agent not found');
86+
87+
const conv = validateConversationOwnership(request.params.conversationId, request.params.id);
88+
if (!conv) return reply.notFound('Conversation not found');
89+
90+
const updated = renameAgentConversation(request.params.conversationId, request.body.subject);
91+
return reply.send(updated);
92+
},
93+
);
94+
95+
// Delete a conversation
96+
typedApp.delete(
97+
'/api/agents/:id/chat/conversations/:conversationId',
98+
{
99+
onRequest: [app.authenticate, requirePermission('settings:update')],
100+
schema: {
101+
tags: ['Agent Chat'],
102+
summary: 'Delete an agent chat conversation and its messages',
103+
params: z.object({ id: z.string(), conversationId: z.string() }),
104+
},
105+
},
106+
async (request, reply) => {
107+
const agent = getAgent(request.params.id);
108+
if (!agent) return reply.notFound('Agent not found');
109+
110+
const conv = validateConversationOwnership(request.params.conversationId, request.params.id);
111+
if (!conv) return reply.notFound('Conversation not found');
112+
113+
deleteAgentConversation(request.params.conversationId);
114+
return reply.status(204).send();
115+
},
116+
);
117+
118+
// List chat messages for a specific conversation
119+
typedApp.get(
120+
'/api/agents/:id/chat/messages',
121+
{
122+
onRequest: [app.authenticate, requirePermission('settings:read')],
123+
schema: {
124+
tags: ['Agent Chat'],
125+
summary: 'List chat messages for an agent conversation',
126+
params: z.object({ id: z.string() }),
127+
querystring: z.object({
128+
conversationId: z.string(),
129+
limit: z.coerce.number().int().min(1).max(200).default(100),
130+
offset: z.coerce.number().int().min(0).default(0),
131+
}),
132+
},
133+
},
134+
async (request, reply) => {
135+
const agent = getAgent(request.params.id);
136+
if (!agent) return reply.notFound('Agent not found');
137+
138+
const conv = validateConversationOwnership(request.query.conversationId, request.params.id);
139+
if (!conv) return reply.notFound('Conversation not found');
140+
141+
const all = store
142+
.find(
143+
'messages',
144+
(r: Record<string, unknown>) => r.conversationId === request.query.conversationId,
145+
)
146+
.sort(
147+
(a: Record<string, unknown>, b: Record<string, unknown>) =>
148+
new Date(a.createdAt as string).getTime() -
149+
new Date(b.createdAt as string).getTime(),
150+
);
151+
152+
const { limit, offset } = request.query;
153+
const entries = all.slice(offset, offset + limit);
154+
return reply.send({ total: all.length, limit, offset, entries });
155+
},
156+
);
157+
158+
// Append a message to an agent chat conversation (for agent progress/final updates)
159+
typedApp.post(
160+
'/api/agents/:id/chat/messages',
161+
{
162+
onRequest: [app.authenticate, requirePermission('messages:send')],
163+
schema: {
164+
tags: ['Agent Chat'],
165+
summary: 'Append a message to an agent chat conversation',
166+
params: z.object({ id: z.string() }),
167+
body: z.object({
168+
conversationId: z.string(),
169+
content: z.string().min(1).max(50000),
170+
isFinal: z.boolean().optional(),
171+
}),
172+
},
173+
},
174+
async (request, reply) => {
175+
const agent = getAgent(request.params.id);
176+
if (!agent) return reply.notFound('Agent not found');
177+
178+
const conv = validateConversationOwnership(request.body.conversationId, request.params.id);
179+
if (!conv) return reply.notFound('Conversation not found');
180+
181+
const message = saveAgentConversationMessage({
182+
conversationId: request.body.conversationId,
183+
direction: 'inbound',
184+
content: request.body.content,
185+
type: request.body.isFinal ? 'text' : 'system',
186+
metadata: {
187+
agentChatUpdate: true,
188+
isFinal: Boolean(request.body.isFinal),
189+
},
190+
});
191+
192+
return reply.status(201).send(message);
193+
},
194+
);
195+
196+
// Send a prompt (SSE streaming)
197+
typedApp.post(
198+
'/api/agents/:id/chat/message',
199+
{
200+
onRequest: [app.authenticate, requirePermission('settings:update')],
201+
schema: {
202+
tags: ['Agent Chat'],
203+
summary: 'Send a prompt to the agent and stream the response via SSE',
204+
params: z.object({ id: z.string() }),
205+
body: z.object({
206+
prompt: z.string().min(1).max(50000),
207+
conversationId: z.string(),
208+
}),
209+
},
210+
},
211+
async (request, reply) => {
212+
const agent = getAgent(request.params.id);
213+
if (!agent) return reply.notFound('Agent not found');
214+
215+
if (isAgentBusy(request.params.id, request.body.conversationId)) {
216+
return reply.status(409).send({
217+
statusCode: 409,
218+
error: 'Conflict',
219+
message: 'Agent is already processing a prompt',
220+
});
221+
}
222+
223+
const conv = validateConversationOwnership(request.body.conversationId, request.params.id);
224+
if (!conv) return reply.notFound('Conversation not found');
225+
226+
// Set SSE headers
227+
reply.raw.writeHead(200, {
228+
'Content-Type': 'text/event-stream',
229+
'Cache-Control': 'no-cache',
230+
Connection: 'keep-alive',
231+
'X-Accel-Buffering': 'no',
232+
});
233+
234+
executePrompt(request.params.id, request.body.prompt, request.body.conversationId, {
235+
onChunk(text) {
236+
reply.raw.write(`data: ${JSON.stringify(text)}\n\n`);
237+
},
238+
onDone(message) {
239+
reply.raw.write(`event: done\ndata: ${JSON.stringify({ messageId: message.id })}\n\n`);
240+
reply.raw.end();
241+
},
242+
onError(error) {
243+
reply.raw.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`);
244+
reply.raw.end();
245+
},
246+
});
247+
},
248+
);
249+
}

0 commit comments

Comments
 (0)