@zhijiewang/openharness 0.12.1 → 1.2.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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Multi-Model Router — task-aware model selection.
3
+ *
4
+ * Routes LLM calls to the most appropriate model based on task context:
5
+ * - Fast model for exploration, search, and tool-heavy turns
6
+ * - Powerful model for final responses, code review, and complex reasoning
7
+ * - Balanced model as the default
8
+ *
9
+ * Saves ~20% cost and ~30% latency by avoiding expensive models for simple tasks.
10
+ */
11
+ export type ModelTier = 'fast' | 'balanced' | 'powerful';
12
+ export type RouterConfig = {
13
+ fast?: string;
14
+ balanced?: string;
15
+ powerful?: string;
16
+ };
17
+ export type RouteContext = {
18
+ /** Current turn number (1-indexed) */
19
+ turn: number;
20
+ /** Whether the previous turn had tool calls */
21
+ hadToolCalls: boolean;
22
+ /** Number of tool calls in previous turn */
23
+ toolCallCount: number;
24
+ /** Agent role (if sub-agent) */
25
+ role?: string;
26
+ /** Estimated context usage (0-1) */
27
+ contextUsage?: number;
28
+ /** Whether this is likely the final response (no tool calls expected) */
29
+ isFinalResponse?: boolean;
30
+ };
31
+ export type RouteResult = {
32
+ model: string;
33
+ tier: ModelTier;
34
+ reason: string;
35
+ };
36
+ export declare class ModelRouter {
37
+ private config;
38
+ private defaultModel;
39
+ constructor(config: RouterConfig, defaultModel: string);
40
+ /** Select the best model for the current context */
41
+ select(context: RouteContext): RouteResult;
42
+ private route;
43
+ /** Whether this router has any non-default models configured */
44
+ get isConfigured(): boolean;
45
+ /** Get all configured tiers */
46
+ get tiers(): Record<ModelTier, string>;
47
+ }
48
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Multi-Model Router — task-aware model selection.
3
+ *
4
+ * Routes LLM calls to the most appropriate model based on task context:
5
+ * - Fast model for exploration, search, and tool-heavy turns
6
+ * - Powerful model for final responses, code review, and complex reasoning
7
+ * - Balanced model as the default
8
+ *
9
+ * Saves ~20% cost and ~30% latency by avoiding expensive models for simple tasks.
10
+ */
11
+ export class ModelRouter {
12
+ config;
13
+ defaultModel;
14
+ constructor(config, defaultModel) {
15
+ this.config = config;
16
+ this.defaultModel = defaultModel;
17
+ }
18
+ /** Select the best model for the current context */
19
+ select(context) {
20
+ // High-context pressure → use fast model to minimize token cost
21
+ if (context.contextUsage && context.contextUsage > 0.8) {
22
+ return this.route('fast', 'context pressure > 80%');
23
+ }
24
+ // Roles that require deep reasoning → powerful
25
+ const powerfulRoles = ['code-reviewer', 'evaluator', 'architect', 'security-auditor'];
26
+ if (context.role && powerfulRoles.includes(context.role)) {
27
+ return this.route('powerful', `role: ${context.role}`);
28
+ }
29
+ // Early exploration turns (1-2) → fast
30
+ if (context.turn <= 2 && context.hadToolCalls) {
31
+ return this.route('fast', 'early exploration');
32
+ }
33
+ // Tool-heavy turns (3+ tool calls) → fast (just dispatching)
34
+ if (context.toolCallCount >= 3) {
35
+ return this.route('fast', 'tool-heavy turn');
36
+ }
37
+ // Final response (no tool calls) → powerful for quality
38
+ if (context.isFinalResponse) {
39
+ return this.route('powerful', 'final response');
40
+ }
41
+ // Default → balanced
42
+ return this.route('balanced', 'default');
43
+ }
44
+ route(tier, reason) {
45
+ const model = this.config[tier] ?? this.defaultModel;
46
+ return { model, tier, reason };
47
+ }
48
+ /** Whether this router has any non-default models configured */
49
+ get isConfigured() {
50
+ return !!(this.config.fast || this.config.balanced || this.config.powerful);
51
+ }
52
+ /** Get all configured tiers */
53
+ get tiers() {
54
+ return {
55
+ fast: this.config.fast ?? this.defaultModel,
56
+ balanced: this.config.balanced ?? this.defaultModel,
57
+ powerful: this.config.powerful ?? this.defaultModel,
58
+ };
59
+ }
60
+ }
61
+ //# sourceMappingURL=router.js.map
@@ -4,6 +4,11 @@
4
4
  */
5
5
  import type { Message } from "../types/message.js";
6
6
  import type { Provider } from "../providers/base.js";
7
+ /**
8
+ * Semantic importance scoring for messages.
9
+ * Higher score = more important to keep during compression.
10
+ */
11
+ export declare function scoreMessage(msg: Message, index: number, total: number): number;
7
12
  export declare function makeTokenEstimator(provider: Provider): (text: string) => number;
8
13
  export declare function estimateMessagesTokens(messages: Message[], estimateTokens?: (text: string) => number): number;
9
14
  /**
@@ -5,6 +5,35 @@
5
5
  import { createUserMessage } from "../types/message.js";
6
6
  import { defaultEstimateTokens } from "../providers/base.js";
7
7
  const DEFAULT_KEEP_LAST = 10;
8
+ /**
9
+ * Semantic importance scoring for messages.
10
+ * Higher score = more important to keep during compression.
11
+ */
12
+ export function scoreMessage(msg, index, total) {
13
+ if (msg.meta?.pinned)
14
+ return Infinity;
15
+ let score = 0;
16
+ // Role weight: user intent > tool decisions > assistant text
17
+ if (msg.role === 'user')
18
+ score += 30;
19
+ else if (msg.role === 'assistant' && msg.toolCalls?.length)
20
+ score += 20;
21
+ else if (msg.role === 'assistant')
22
+ score += 10;
23
+ else if (msg.role === 'system')
24
+ score += 25; // system messages are usually important
25
+ else if (msg.role === 'tool')
26
+ score += 5;
27
+ // Recency bonus: recent messages get +0 to +20
28
+ const recencyFactor = index / total;
29
+ score += recencyFactor * 20;
30
+ // Content length (longer = more substantive, but cap benefit)
31
+ score += Math.min(msg.content.length / 200, 5);
32
+ // Tool calls indicate decision points
33
+ if (msg.toolCalls?.length)
34
+ score += msg.toolCalls.length * 3;
35
+ return score;
36
+ }
8
37
  export function makeTokenEstimator(provider) {
9
38
  if (provider.estimateTokens)
10
39
  return provider.estimateTokens.bind(provider);
@@ -58,12 +87,24 @@ export function compressMessages(messages, targetTokens) {
58
87
  result[i] = { ...result[i], content: "[previous tool result truncated]" };
59
88
  }
60
89
  }
61
- // AutoCompact Phase 2: Drop oldest non-system, non-pinned messages
90
+ // AutoCompact Phase 2: Drop lowest-importance messages first (importance-aware)
62
91
  while (estimateMessagesTokens(result) > targetTokens && result.length > keepLast + 1) {
63
- const firstDroppable = result.findIndex((m) => m.role !== "system" && !m.meta?.pinned);
64
- if (firstDroppable === -1 || firstDroppable >= result.length - keepLast)
92
+ // Score all droppable messages and remove the lowest-scored
93
+ let lowestScore = Infinity;
94
+ let lowestIdx = -1;
95
+ for (let i = 0; i < result.length - keepLast; i++) {
96
+ const msg = result[i];
97
+ if (msg.role === 'system' || msg.meta?.pinned)
98
+ continue;
99
+ const score = scoreMessage(msg, i, result.length);
100
+ if (score < lowestScore) {
101
+ lowestScore = score;
102
+ lowestIdx = i;
103
+ }
104
+ }
105
+ if (lowestIdx === -1)
65
106
  break;
66
- result.splice(firstDroppable, 1);
107
+ result.splice(lowestIdx, 1);
67
108
  }
68
109
  // Phase 3: Remove orphaned tool results
69
110
  const validCallIds = new Set();
@@ -0,0 +1,25 @@
1
+ /**
2
+ * API security layer — auth, rate limiting, and tool access control
3
+ * for the remote server.
4
+ */
5
+ import type { IncomingMessage, ServerResponse } from 'node:http';
6
+ import type { Tools } from '../Tool.js';
7
+ /** Check if a request is within rate limits. Returns true if allowed. */
8
+ export declare function checkRateLimit(ip: string, maxPerMinute: number): boolean;
9
+ /** Validate a bearer token against configured tokens. Returns true if valid. */
10
+ export declare function validateToken(authHeader: string | undefined): boolean;
11
+ /** Filter tools based on remote config allowlist */
12
+ export declare function filterRemoteTools(tools: Tools): Tools;
13
+ /** Generate a unique request ID */
14
+ export declare function generateRequestId(): string;
15
+ export type AuthResult = {
16
+ allowed: boolean;
17
+ reason?: string;
18
+ requestId: string;
19
+ };
20
+ /**
21
+ * Run auth checks on an incoming request.
22
+ * Returns allowed=true if the request should proceed.
23
+ */
24
+ export declare function authenticateRequest(req: IncomingMessage, res: ServerResponse): AuthResult;
25
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * API security layer — auth, rate limiting, and tool access control
3
+ * for the remote server.
4
+ */
5
+ import { readOhConfig } from '../harness/config.js';
6
+ const rateLimitMap = new Map();
7
+ const WINDOW_MS = 60_000; // 1 minute sliding window
8
+ /** Check if a request is within rate limits. Returns true if allowed. */
9
+ export function checkRateLimit(ip, maxPerMinute) {
10
+ const now = Date.now();
11
+ const entry = rateLimitMap.get(ip);
12
+ if (!entry || now - entry.windowStart > WINDOW_MS) {
13
+ rateLimitMap.set(ip, { count: 1, windowStart: now });
14
+ return true;
15
+ }
16
+ if (entry.count >= maxPerMinute)
17
+ return false;
18
+ entry.count++;
19
+ return true;
20
+ }
21
+ // ── Token Auth ──
22
+ /** Validate a bearer token against configured tokens. Returns true if valid. */
23
+ export function validateToken(authHeader) {
24
+ const config = readOhConfig();
25
+ const tokens = config?.remote?.tokens;
26
+ // No tokens configured = no auth required (open access)
27
+ if (!tokens || tokens.length === 0)
28
+ return true;
29
+ if (!authHeader)
30
+ return false;
31
+ const token = authHeader.replace(/^Bearer\s+/i, '').trim();
32
+ if (!token)
33
+ return false;
34
+ return tokens.includes(token);
35
+ }
36
+ // ── Tool Filtering ──
37
+ /** Filter tools based on remote config allowlist */
38
+ export function filterRemoteTools(tools) {
39
+ const config = readOhConfig();
40
+ const allowed = config?.remote?.allowedTools;
41
+ if (!allowed || allowed.length === 0)
42
+ return tools;
43
+ const allowSet = new Set(allowed.map(n => n.toLowerCase()));
44
+ const filtered = tools.filter(t => allowSet.has(t.name.toLowerCase()));
45
+ return filtered.length > 0 ? filtered : tools; // fallback to all if filter empties
46
+ }
47
+ // ── Request ID ──
48
+ let requestCounter = 0;
49
+ /** Generate a unique request ID */
50
+ export function generateRequestId() {
51
+ return `req-${Date.now().toString(36)}-${(++requestCounter).toString(36)}`;
52
+ }
53
+ /**
54
+ * Run auth checks on an incoming request.
55
+ * Returns allowed=true if the request should proceed.
56
+ */
57
+ export function authenticateRequest(req, res) {
58
+ const requestId = generateRequestId();
59
+ res.setHeader('X-Request-ID', requestId);
60
+ // Token auth
61
+ if (!validateToken(req.headers.authorization)) {
62
+ return { allowed: false, reason: 'Invalid or missing bearer token', requestId };
63
+ }
64
+ // Rate limiting
65
+ const config = readOhConfig();
66
+ const rateLimit = config?.remote?.rateLimit ?? 60; // default 60/min
67
+ const ip = req.socket.remoteAddress ?? 'unknown';
68
+ if (!checkRateLimit(ip, rateLimit)) {
69
+ return { allowed: false, reason: 'Rate limit exceeded', requestId };
70
+ }
71
+ return { allowed: true, requestId };
72
+ }
73
+ //# sourceMappingURL=auth.js.map
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Remote server — HTTP + WebSocket server for remote agent dispatch,
3
- * bidirectional channels, and structured event streaming.
3
+ * bidirectional channels, A2A protocol, and structured event streaming.
4
4
  *
5
5
  * Endpoints:
6
- * - POST /dispatch — send a prompt, get a streaming response
6
+ * - POST /dispatch — send a prompt, get a streaming response (SSE)
7
+ * - POST /a2a — A2A protocol: task delegation, discovery, status
7
8
  * - GET /status — check server status
8
9
  * - WS /channel — bidirectional WebSocket channel
10
+ *
11
+ * Security: bearer token auth, per-IP rate limiting, tool allowlists.
9
12
  */
10
13
  import type { Provider } from '../providers/base.js';
11
14
  import type { Tools } from '../Tool.js';
@@ -17,15 +20,28 @@ export type RemoteServerConfig = {
17
20
  systemPrompt: string;
18
21
  permissionMode: PermissionMode;
19
22
  model?: string;
23
+ sessionId?: string;
20
24
  };
21
25
  export declare class RemoteServer {
22
26
  private config;
23
27
  private channels;
24
28
  private server;
29
+ private agentCardId;
25
30
  constructor(config: RemoteServerConfig);
26
31
  start(): Promise<void>;
27
32
  stop(): void;
28
33
  private handleHttp;
34
+ private handleDispatch;
35
+ /**
36
+ * A2A protocol handler — receives inter-agent messages.
37
+ *
38
+ * Supports:
39
+ * - task: delegate a task to this agent
40
+ * - discover: return this agent's capabilities
41
+ * - status: return current state
42
+ * - cancel: abort a running task
43
+ */
44
+ private handleA2A;
29
45
  private handleChannel;
30
46
  }
31
47
  //# sourceMappingURL=server.d.ts.map
@@ -1,18 +1,24 @@
1
1
  /**
2
2
  * Remote server — HTTP + WebSocket server for remote agent dispatch,
3
- * bidirectional channels, and structured event streaming.
3
+ * bidirectional channels, A2A protocol, and structured event streaming.
4
4
  *
5
5
  * Endpoints:
6
- * - POST /dispatch — send a prompt, get a streaming response
6
+ * - POST /dispatch — send a prompt, get a streaming response (SSE)
7
+ * - POST /a2a — A2A protocol: task delegation, discovery, status
7
8
  * - GET /status — check server status
8
9
  * - WS /channel — bidirectional WebSocket channel
10
+ *
11
+ * Security: bearer token auth, per-IP rate limiting, tool allowlists.
9
12
  */
10
13
  import { createServer } from 'node:http';
11
14
  import { WebSocketServer, WebSocket } from 'ws';
15
+ import { authenticateRequest, filterRemoteTools } from './auth.js';
16
+ import { createSessionCard, publishCard, unpublishCard, discoverAgents, generateMessageId, } from '../services/a2a.js';
12
17
  export class RemoteServer {
13
18
  config;
14
19
  channels = new Map();
15
20
  server = null;
21
+ agentCardId = null;
16
22
  constructor(config) {
17
23
  this.config = config;
18
24
  }
@@ -33,12 +39,27 @@ export class RemoteServer {
33
39
  });
34
40
  this.server.listen(this.config.port, () => {
35
41
  process.stderr.write(`[remote] Server listening on http://localhost:${this.config.port}\n`);
36
- process.stderr.write(`[remote] Endpoints: POST /dispatch, GET /status, WS /channel\n`);
42
+ process.stderr.write(`[remote] Endpoints: POST /dispatch, POST /a2a, GET /status, WS /channel\n`);
43
+ // Publish A2A agent card with HTTP endpoint
44
+ const sessionId = this.config.sessionId ?? Date.now().toString(36);
45
+ const card = createSessionCard(sessionId, {
46
+ provider: this.config.provider.name,
47
+ model: this.config.model,
48
+ port: this.config.port,
49
+ });
50
+ publishCard(card);
51
+ this.agentCardId = card.id;
52
+ process.stderr.write(`[remote] A2A agent card published: ${card.id}\n`);
37
53
  resolve();
38
54
  });
39
55
  });
40
56
  }
41
57
  stop() {
58
+ // Unpublish A2A card
59
+ if (this.agentCardId) {
60
+ unpublishCard(this.agentCardId);
61
+ this.agentCardId = null;
62
+ }
42
63
  for (const ch of this.channels.values()) {
43
64
  ch.abortController.abort();
44
65
  ch.ws.close();
@@ -50,12 +71,23 @@ export class RemoteServer {
50
71
  // CORS headers
51
72
  res.setHeader('Access-Control-Allow-Origin', '*');
52
73
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
53
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
74
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
54
75
  if (req.method === 'OPTIONS') {
55
76
  res.writeHead(204);
56
77
  res.end();
57
78
  return;
58
79
  }
80
+ // Auth check (skip for /status which is a health check)
81
+ if (req.url !== '/status') {
82
+ const auth = authenticateRequest(req, res);
83
+ if (!auth.allowed) {
84
+ const status = auth.reason?.includes('Rate limit') ? 429 : 401;
85
+ res.writeHead(status, { 'Content-Type': 'application/json' });
86
+ res.end(JSON.stringify({ error: auth.reason, requestId: auth.requestId }));
87
+ return;
88
+ }
89
+ }
90
+ // ── GET /status ──
59
91
  if (req.url === '/status' && req.method === 'GET') {
60
92
  res.writeHead(200, { 'Content-Type': 'application/json' });
61
93
  res.end(JSON.stringify({
@@ -63,50 +95,146 @@ export class RemoteServer {
63
95
  provider: this.config.provider.name,
64
96
  model: this.config.model,
65
97
  channels: this.channels.size,
98
+ agentId: this.agentCardId,
66
99
  }));
67
100
  return;
68
101
  }
102
+ // ── POST /dispatch ──
69
103
  if (req.url === '/dispatch' && req.method === 'POST') {
70
- const body = await readBody(req);
71
- try {
72
- const { prompt, maxTurns } = JSON.parse(body);
73
- if (!prompt) {
74
- res.writeHead(400, { 'Content-Type': 'application/json' });
75
- res.end(JSON.stringify({ error: 'Missing "prompt" field' }));
104
+ await this.handleDispatch(req, res);
105
+ return;
106
+ }
107
+ // ── POST /a2a ──
108
+ if (req.url === '/a2a' && req.method === 'POST') {
109
+ await this.handleA2A(req, res);
110
+ return;
111
+ }
112
+ res.writeHead(404, { 'Content-Type': 'application/json' });
113
+ res.end(JSON.stringify({ error: 'Not found' }));
114
+ }
115
+ async handleDispatch(req, res) {
116
+ const body = await readBody(req);
117
+ try {
118
+ const { prompt, maxTurns } = JSON.parse(body);
119
+ if (!prompt) {
120
+ res.writeHead(400, { 'Content-Type': 'application/json' });
121
+ res.end(JSON.stringify({ error: 'Missing "prompt" field' }));
122
+ return;
123
+ }
124
+ // Apply tool filtering for remote callers
125
+ const tools = filterRemoteTools(this.config.tools);
126
+ // Stream response as Server-Sent Events
127
+ res.writeHead(200, {
128
+ 'Content-Type': 'text/event-stream',
129
+ 'Cache-Control': 'no-cache',
130
+ 'Connection': 'keep-alive',
131
+ });
132
+ const { query } = await import('../query.js');
133
+ const config = {
134
+ provider: this.config.provider,
135
+ tools,
136
+ systemPrompt: this.config.systemPrompt,
137
+ permissionMode: this.config.permissionMode,
138
+ model: this.config.model,
139
+ maxTurns: maxTurns ?? 20,
140
+ };
141
+ for await (const event of query(prompt, config)) {
142
+ const data = JSON.stringify(event);
143
+ res.write(`data: ${data}\n\n`);
144
+ }
145
+ res.write('data: [DONE]\n\n');
146
+ res.end();
147
+ }
148
+ catch (err) {
149
+ if (!res.headersSent) {
150
+ res.writeHead(500, { 'Content-Type': 'application/json' });
151
+ }
152
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
153
+ }
154
+ }
155
+ /**
156
+ * A2A protocol handler — receives inter-agent messages.
157
+ *
158
+ * Supports:
159
+ * - task: delegate a task to this agent
160
+ * - discover: return this agent's capabilities
161
+ * - status: return current state
162
+ * - cancel: abort a running task
163
+ */
164
+ async handleA2A(req, res) {
165
+ const body = await readBody(req);
166
+ try {
167
+ const message = JSON.parse(body);
168
+ switch (message.payload.kind) {
169
+ case 'discover': {
170
+ // Return our agent card
171
+ const agents = discoverAgents();
172
+ const self = agents.find(a => a.id === this.agentCardId);
173
+ const response = {
174
+ id: generateMessageId(),
175
+ from: this.agentCardId ?? 'unknown',
176
+ to: message.from,
177
+ type: 'result',
178
+ payload: { kind: 'result', taskId: message.id, output: self ?? { error: 'agent not found' } },
179
+ timestamp: Date.now(),
180
+ };
181
+ res.writeHead(200, { 'Content-Type': 'application/json' });
182
+ res.end(JSON.stringify(response));
76
183
  return;
77
184
  }
78
- // Stream response as Server-Sent Events
79
- res.writeHead(200, {
80
- 'Content-Type': 'text/event-stream',
81
- 'Cache-Control': 'no-cache',
82
- 'Connection': 'keep-alive',
83
- });
84
- const { query } = await import('../query.js');
85
- const config = {
86
- provider: this.config.provider,
87
- tools: this.config.tools,
88
- systemPrompt: this.config.systemPrompt,
89
- permissionMode: this.config.permissionMode,
90
- model: this.config.model,
91
- maxTurns: maxTurns ?? 20,
92
- };
93
- for await (const event of query(prompt, config)) {
94
- const data = JSON.stringify(event);
95
- res.write(`data: ${data}\n\n`);
185
+ case 'task': {
186
+ // Execute the task via query loop
187
+ const tools = filterRemoteTools(this.config.tools);
188
+ const { query } = await import('../query.js');
189
+ const config = {
190
+ provider: this.config.provider,
191
+ tools,
192
+ systemPrompt: `[A2A Task from agent ${message.from}]\n\n${this.config.systemPrompt}`,
193
+ permissionMode: this.config.permissionMode,
194
+ model: this.config.model,
195
+ maxTurns: 10,
196
+ };
197
+ let output = '';
198
+ for await (const event of query(String(message.payload.input), config)) {
199
+ if (event.type === 'text_delta')
200
+ output += event.content;
201
+ }
202
+ const response = {
203
+ id: generateMessageId(),
204
+ from: this.agentCardId ?? 'unknown',
205
+ to: message.from,
206
+ type: 'result',
207
+ payload: { kind: 'result', taskId: message.id, output },
208
+ timestamp: Date.now(),
209
+ };
210
+ res.writeHead(200, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify(response));
212
+ return;
96
213
  }
97
- res.write('data: [DONE]\n\n');
98
- res.end();
99
- }
100
- catch (err) {
101
- if (!res.headersSent) {
102
- res.writeHead(500, { 'Content-Type': 'application/json' });
214
+ case 'status': {
215
+ const response = {
216
+ id: generateMessageId(),
217
+ from: this.agentCardId ?? 'unknown',
218
+ to: message.from,
219
+ type: 'status',
220
+ payload: { kind: 'status', state: 'idle' },
221
+ timestamp: Date.now(),
222
+ };
223
+ res.writeHead(200, { 'Content-Type': 'application/json' });
224
+ res.end(JSON.stringify(response));
225
+ return;
226
+ }
227
+ default: {
228
+ res.writeHead(400, { 'Content-Type': 'application/json' });
229
+ res.end(JSON.stringify({ error: `Unknown A2A message kind: ${message.payload.kind}` }));
230
+ return;
103
231
  }
104
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
105
232
  }
106
- return;
107
233
  }
108
- res.writeHead(404, { 'Content-Type': 'application/json' });
109
- res.end(JSON.stringify({ error: 'Not found' }));
234
+ catch (err) {
235
+ res.writeHead(400, { 'Content-Type': 'application/json' });
236
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
237
+ }
110
238
  }
111
239
  handleChannel(ws) {
112
240
  const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
@@ -119,10 +247,11 @@ export class RemoteServer {
119
247
  try {
120
248
  const msg = JSON.parse(data.toString());
121
249
  if (msg.type === 'dispatch') {
250
+ const tools = filterRemoteTools(this.config.tools);
122
251
  const { query } = await import('../query.js');
123
252
  const config = {
124
253
  provider: this.config.provider,
125
- tools: this.config.tools,
254
+ tools,
126
255
  systemPrompt: this.config.systemPrompt,
127
256
  permissionMode: this.config.permissionMode,
128
257
  model: this.config.model,
@@ -86,6 +86,13 @@ export declare class TerminalRenderer {
86
86
  private handlePermissionKey;
87
87
  /** Handle question prompt text input. Returns true if key was consumed. */
88
88
  private handleQuestionKey;
89
+ private renderTimer;
90
+ private lastRenderTime;
91
+ private static readonly FRAME_MS;
92
+ /**
93
+ * Schedule a render at ~60fps. Multiple calls within a frame are batched.
94
+ * This prevents excessive re-renders during fast token streaming.
95
+ */
89
96
  private scheduleRender;
90
97
  /** Apply lightweight markdown styling to a line for scrollback output */
91
98
  private styleMarkdownLine;