@bbigbang/channel-bridge 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/dist/index.js ADDED
@@ -0,0 +1,2065 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * channel-bridge — MCP server for bigbang agents.
4
+ *
5
+ * Mirrors Slock's chat-bridge.js: exposes send_message, check_messages,
6
+ * list_server, read_history, and task board tools over MCP stdio transport.
7
+ * The tools call bigbang's internal agent API (/api/internal/agent/:id/*).
8
+ *
9
+ * Usage:
10
+ * channel-bridge --agent-id <id> --server-url <url> [--auth-token <token>]
11
+ */
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+ import { basename, extname } from 'node:path';
14
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
+ import { z } from 'zod';
17
+ import { buildCachedAssetFilePath, findCachedAssetFile, readCachedAssetMetadata, resolveConversationAssetCacheRoot, writeCachedAssetMetadata, } from './assetCache.js';
18
+ import { formatBeijingPromptTimestamp, formatHistoryMessages, formatMessages } from './messageFormat.js';
19
+ import { buildCompactRuntimePresenceData, buildCompactSelfStateData, formatConversationListText, formatConversationSummaryToolText, formatRuntimePresenceToolText, formatSendMessageDiagnostics, formatSelfStateToolText, formatToolResult, } from './surfaceFormats.js';
20
+ import { buildCompactWorkspaceInspectData, formatWorkspaceFileToolText, formatWorkspaceInspectToolText, formatWorkspaceTreeToolText } from './workspaceFormats.js';
21
+ import { formatContextBundleToolText } from './contextBundleFormat.js';
22
+ import { formatTaskUpdateDeliveriesSection, formatTaskUpdateDeliveryText } from './taskUpdateFormat.js';
23
+ import { buildInlineTextPreview } from './textPreview.js';
24
+ import { formatComponentRegistry, formatPanelState, formatPanelRows, formatPanelEvents, formatPanelCollaborators, formatRenderPanelSuccess, formatRenderPanelError, formatUpsertPanelError, formatPatchPanelSuccess, formatPatchPanelError, } from './panelFormat.js';
25
+ // ─── CLI args ─────────────────────────────────────────────────────────────────
26
+ const args = process.argv.slice(2);
27
+ let agentId = '';
28
+ let conversationId = '';
29
+ let runId = '';
30
+ let runContextPath = '';
31
+ let serverUrl = 'http://localhost:3100';
32
+ let authToken = process.env.CHANNEL_BRIDGE_AUTH_TOKEN ?? '';
33
+ let workspacePath = '';
34
+ let assetCacheRootArg = '';
35
+ for (let i = 0; i < args.length; i++) {
36
+ if (args[i] === '--agent-id' && args[i + 1])
37
+ agentId = args[++i];
38
+ if (args[i] === '--conversation-id' && args[i + 1])
39
+ conversationId = args[++i];
40
+ if (args[i] === '--run-id' && args[i + 1])
41
+ runId = args[++i];
42
+ if (args[i] === '--run-context-path' && args[i + 1])
43
+ runContextPath = args[++i];
44
+ if (args[i] === '--server-url' && args[i + 1])
45
+ serverUrl = args[++i];
46
+ if (args[i] === '--auth-token' && args[i + 1])
47
+ authToken = args[++i];
48
+ if (args[i] === '--workspace-path' && args[i + 1])
49
+ workspacePath = args[++i];
50
+ if (args[i] === '--asset-cache-root' && args[i + 1])
51
+ assetCacheRootArg = args[++i];
52
+ }
53
+ if (!agentId || !conversationId) {
54
+ console.error('[channel-bridge] Missing --agent-id or --conversation-id');
55
+ process.exit(1);
56
+ }
57
+ const panelActionParamFieldSchema = z.object({
58
+ name: z.string().trim().min(1),
59
+ label: z.string().trim().min(1).optional(),
60
+ description: z.string().trim().min(1).optional(),
61
+ input: z.enum(['text', 'number', 'checkbox', 'select']).optional(),
62
+ required: z.boolean().optional(),
63
+ placeholder: z.string().optional(),
64
+ defaultValue: z.unknown().optional(),
65
+ min: z.number().finite().optional(),
66
+ max: z.number().finite().optional(),
67
+ options: z.array(z.object({
68
+ label: z.string().trim().min(1),
69
+ value: z.string().trim().min(1),
70
+ })).optional(),
71
+ });
72
+ const panelActionSchema = z.object({
73
+ id: z.string().trim().min(1),
74
+ label: z.string().trim().min(1),
75
+ mode: z.enum(['notify_agent', 'platform_exec']).optional().describe('Optional action mode. Omit for legacy defaults: command or legacy rpcCommand means platform_exec, otherwise notify_agent.'),
76
+ toolKind: z.enum(['start', 'stop', 'status', 'restart', 'custom']).optional().describe('Promotion hint only: when this panel is upgraded to a Workspace Tool, map the action to this tool action kind. Ignored by ordinary panel action execution.'),
77
+ variant: z.enum(['primary', 'secondary', 'danger']).optional(),
78
+ description: z.string().optional(),
79
+ command: z.string().optional().describe('Required for mode="platform_exec" while the panel is used directly, except commandless toolKind="stop" is allowed as a promotion hint.'),
80
+ cwd: z.string().optional().describe('Optional workspace-relative cwd for command.'),
81
+ persistent: z.boolean().optional().describe('Promotion hint copied to Workspace Tool actions; persistent true defaults direct promotion to toolKind="start" when toolKind is omitted.'),
82
+ maxRunSeconds: z.number().int().positive().optional().describe('Promotion hint copied to Workspace Tool persistent start/restart actions.'),
83
+ idleTimeoutSeconds: z.number().int().positive().optional().describe('Promotion hint copied to Workspace Tool persistent start/restart actions.'),
84
+ paramsSchema: z.array(panelActionParamFieldSchema).optional().describe('Promotion hint copied to Workspace Tool actions. Ordinary panel action execution does not prompt for these parameters; the Tools surface does after upgrade.'),
85
+ rpcCommand: z.string().optional().describe('Deprecated legacy alias for command; accepted for compatibility.'),
86
+ rpcCwd: z.string().optional().describe('Deprecated legacy alias for cwd; accepted for compatibility.'),
87
+ });
88
+ void workspacePath;
89
+ // ─── HTTP helpers ─────────────────────────────────────────────────────────────
90
+ const commonHeaders = { 'Content-Type': 'application/json' };
91
+ if (authToken)
92
+ commonHeaders['Authorization'] = `Bearer ${authToken}`;
93
+ commonHeaders['X-Bigbang-Agent-Surface'] = 'mcp';
94
+ const MIME_BY_EXTENSION = {
95
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
96
+ '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
97
+ '.txt': 'text/plain', '.md': 'text/markdown', '.log': 'text/plain',
98
+ '.json': 'application/json', '.yaml': 'application/yaml', '.yml': 'application/yaml',
99
+ '.xml': 'application/xml', '.csv': 'text/csv',
100
+ '.js': 'text/javascript', '.mjs': 'text/javascript', '.ts': 'text/typescript', '.tsx': 'text/typescript',
101
+ '.jsx': 'text/javascript', '.py': 'text/x-python', '.sh': 'text/x-shellscript',
102
+ '.css': 'text/css', '.html': 'text/html', '.sql': 'text/plain',
103
+ };
104
+ const base = `${serverUrl}/api/internal/agent/${agentId}`;
105
+ function currentRunId() {
106
+ if (runContextPath) {
107
+ try {
108
+ const parsed = JSON.parse(readFileSync(runContextPath, 'utf8'));
109
+ const candidate = typeof parsed.runId === 'string' ? parsed.runId.trim() : '';
110
+ if (candidate)
111
+ return candidate;
112
+ }
113
+ catch {
114
+ // The bridge may start before the first prompt writes run context.
115
+ }
116
+ }
117
+ return runId || undefined;
118
+ }
119
+ async function apiFetch(path, options) {
120
+ const res = await fetch(`${base}${path}`, {
121
+ method: options?.method ?? 'GET',
122
+ headers: commonHeaders,
123
+ body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
124
+ });
125
+ const data = await res.json().catch(() => ({}));
126
+ return { ok: res.ok, status: res.status, data };
127
+ }
128
+ function errText(data, fallback) {
129
+ if (data && typeof data === 'object') {
130
+ const record = data;
131
+ if (typeof record.error_code === 'string') {
132
+ return `${String(record.error ?? fallback)}\n\nStructured error:\n${JSON.stringify({
133
+ error_code: record.error_code,
134
+ required_action: record.required_action ?? null,
135
+ source_target: record.source_target ?? null,
136
+ requested_target: record.requested_target ?? null,
137
+ }, null, 2)}`;
138
+ }
139
+ if ('error' in record)
140
+ return String(record.error);
141
+ }
142
+ return fallback;
143
+ }
144
+ function toText(text) {
145
+ return { content: [{ type: 'text', text }] };
146
+ }
147
+ function formatTaskIdentity(agentTaskRef, taskNumber) {
148
+ if (agentTaskRef && taskNumber != null)
149
+ return `${agentTaskRef} · #t${taskNumber}`;
150
+ if (agentTaskRef)
151
+ return agentTaskRef;
152
+ if (taskNumber != null)
153
+ return `#t${taskNumber}`;
154
+ return 'task';
155
+ }
156
+ function normalizeMessageIdForThreadShortId(messageId) {
157
+ const trimmed = messageId.trim().toLowerCase();
158
+ const withoutClientPrefix = trimmed.startsWith('client-') ? trimmed.slice('client-'.length) : trimmed;
159
+ const normalized = withoutClientPrefix.replace(/[^a-z0-9]/g, '');
160
+ return normalized || trimmed.replace(/[^a-z0-9]/g, '');
161
+ }
162
+ function buildThreadShortId(messageId) {
163
+ return normalizeMessageIdForThreadShortId(messageId).slice(0, 16);
164
+ }
165
+ function isThreadTarget(target) {
166
+ if (target.startsWith('dm:@'))
167
+ return target.split(':').length >= 3;
168
+ if (target.startsWith('#'))
169
+ return target.includes(':');
170
+ return false;
171
+ }
172
+ function buildThreadTarget(target, messageId) {
173
+ if (!target || !messageId)
174
+ return null;
175
+ const normalizedTarget = target.trim();
176
+ if (!(normalizedTarget.startsWith('dm:@') || normalizedTarget.startsWith('#')))
177
+ return null;
178
+ if (isThreadTarget(normalizedTarget))
179
+ return normalizedTarget;
180
+ return `${normalizedTarget}:${buildThreadShortId(messageId)}`;
181
+ }
182
+ // ─── MCP server ───────────────────────────────────────────────────────────────
183
+ const server = new McpServer({ name: 'chat', version: '1.0.0' });
184
+ // ── send_message ──────────────────────────────────────────────────────────────
185
+ server.tool('send_message', 'Send a visible message to the current conversation by default. Do not call this tool when you have no user-visible or peer-visible value to add. You may optionally override the target only when replying on this same surface\'s exact target. Do not use target override to move work to another DM, channel, or thread of the same agent; use handoff_to_target first. Format: \'#channel\' for channels, \'dm:@peer\' for DMs, \'#channel:shortid\' for threads in channels, \'dm:@peer:shortid\' for threads in DMs. To start a NEW DM, use \'dm:@person-name\'.', {
186
+ target: z.string().optional().describe('Optional override for where to send. If omitted, the message replies to the current conversation. Use this only when staying on the same surface\'s exact target after normalization. To move work to another same-agent surface, use handoff_to_target instead. Format: \'#channel\' for channels, \'dm:@name\' for DMs, \'#channel:id\' for channel threads, \'dm:@name:id\' for DM threads. Examples: \'#general\', \'dm:@alice\', \'#general:abcd1234ef567890\'.'),
187
+ content: z
188
+ .string()
189
+ .trim()
190
+ .min(1, 'content must not be empty')
191
+ .describe('The message content. Must not be empty or whitespace-only.'),
192
+ kind: z
193
+ .enum(['progress', 'final'])
194
+ .optional()
195
+ .describe('Optional message kind. Use "progress" for interim updates and "final" for the final user-visible answer that completes this run. If omitted, the platform treats the message as a legacy untyped reply.'),
196
+ peer_delivery: z
197
+ .enum(['silent', 'batch', 'immediate'])
198
+ .optional()
199
+ .describe('Optional peer notification delivery for shared channel/thread updates only; do not set this in DMs. Omit this for coordination-relevant updates so peers receive the default "batch" peer inbox delivery. Use "silent" only for visible no-action status, local bookkeeping, duplicate presence, or waiting updates that should not ordinary-notify peers. If unsure, omit this field. Use "immediate" only when collaborators must see this update now; explicit @mentions are already immediate and override silent.'),
200
+ attachment_ids: z.array(z.string()).optional().describe('Optional attachment IDs to include'),
201
+ panel_ids: z.array(z.string()).optional().describe('Optional panel IDs to include with the message. Panels are interactive UI components rendered in the chat. Use render_panel(...) to create a panel, then pass its panel_id here.'),
202
+ tool_ids: z.array(z.string()).optional().describe('Optional Workspace Tool IDs to include with the message. After publish_workspace_tool returns a tool_id, pass it here so the tool is attached to the visible chat bubble.'),
203
+ }, async ({ target, content, kind, peer_delivery, attachment_ids, panel_ids, tool_ids }) => {
204
+ const normalizedContent = content.trim();
205
+ if (!normalizedContent) {
206
+ throw new Error('content must not be empty');
207
+ }
208
+ const { ok, data } = await apiFetch('/send', {
209
+ method: 'POST',
210
+ body: {
211
+ target,
212
+ content: normalizedContent,
213
+ kind,
214
+ peer_delivery,
215
+ conversationId,
216
+ runId: currentRunId(),
217
+ attachmentIds: attachment_ids,
218
+ panelIds: panel_ids,
219
+ toolIds: tool_ids,
220
+ },
221
+ });
222
+ if (!ok) {
223
+ throw new Error(errText(data, 'send failed'));
224
+ }
225
+ const d = data;
226
+ if (d.suppressed === true) {
227
+ const message = typeof d.message === 'string' && d.message.trim()
228
+ ? d.message.trim()
229
+ : 'Message suppressed by platform policy.';
230
+ return toText(message);
231
+ }
232
+ if (d.held === true && d.stale === true) {
233
+ const message = typeof d.message === 'string' && d.message.trim()
234
+ ? d.message.trim()
235
+ : 'Your draft was not sent because newer messages exist on this surface.';
236
+ const readHistory = typeof d.readHistory === 'string' && d.readHistory.trim()
237
+ ? d.readHistory.trim()
238
+ : 'read_history(...)';
239
+ const blockedSeqRange = typeof d.blockedSeqRange === 'string' && d.blockedSeqRange.trim()
240
+ ? d.blockedSeqRange.trim()
241
+ : 'unknown';
242
+ const latestMessages = Array.isArray(d.latestMessages)
243
+ ? d.latestMessages
244
+ .map((item) => item && typeof item === 'object' ? item : null)
245
+ .filter((item) => item !== null)
246
+ .map((item) => {
247
+ const sender = typeof item.senderName === 'string' ? item.senderName : String(item.senderId ?? 'unknown');
248
+ const seq = typeof item.seq === 'number' ? item.seq : String(item.seq ?? '?');
249
+ const content = typeof item.content === 'string' ? item.content : '';
250
+ return `seq=${seq} ${sender}: ${content}`;
251
+ })
252
+ .join('\n')
253
+ : '';
254
+ const latestSection = latestMessages ? `\n\nLatest messages:\n${latestMessages}` : '';
255
+ return toText(`${message}\n\nDraft held; nothing was posted. Newer seq range: ${blockedSeqRange}.${latestSection}\n\nNext: ${readHistory}, then decide whether to send a revised message or stop.`);
256
+ }
257
+ const msgId = String(d.messageId ?? '');
258
+ if (!msgId.trim()) {
259
+ throw new Error('send_message returned success without a messageId. Nothing was confirmed posted; read_history(...) before deciding whether to retry.');
260
+ }
261
+ const deliveredTarget = String(d.target ?? target ?? 'current conversation');
262
+ const threadTarget = buildThreadTarget(deliveredTarget, msgId);
263
+ const replyHint = threadTarget
264
+ ? ` (to reply in this message's thread, use target "${threadTarget}")`
265
+ : '';
266
+ const notificationNote = typeof d.notificationNote === 'string' && d.notificationNote.trim()
267
+ ? `\n\nNotification note: ${d.notificationNote.trim()}`
268
+ : '';
269
+ const diagnosticsNote = formatSendMessageDiagnostics(d);
270
+ return toText(`Message sent to ${deliveredTarget}. Message ID: ${msgId}${replyHint}${notificationNote}${diagnosticsNote}`);
271
+ });
272
+ // ── check_messages ────────────────────────────────────────────────────────────
273
+ server.tool('check_messages', "Check for new messages without waiting. Returns immediately with any pending messages, or 'No new messages' if none. Use this freely during work — at natural breakpoints or whenever you want to see if anything new came in. Optionally filter to a specific channel or DM.", {
274
+ channel: z.string().optional().describe("Optional: filter to a specific channel or DM (e.g. '#general', 'dm:@alice'). Omit to check all channels."),
275
+ }, async ({ channel }) => {
276
+ try {
277
+ const params = new URLSearchParams();
278
+ if (channel)
279
+ params.set('channel', channel);
280
+ params.set('conversationId', conversationId);
281
+ const activeRunId = currentRunId();
282
+ if (activeRunId)
283
+ params.set('runId', activeRunId);
284
+ const qs = `?${params.toString()}`;
285
+ const { ok, data } = await apiFetch(`/receive${qs}`);
286
+ if (!ok)
287
+ return toText(`Error: ${errText(data, 'receive failed')}`);
288
+ const d = data;
289
+ if (d.messages && d.messages.length > 0) {
290
+ const formatted = formatMessages(d.messages);
291
+ return toText(formatted +
292
+ '\n\n--- IMPORTANT: The [Message metadata] block is system metadata for routing and context. Do NOT quote or repeat it back to the user. Reply using mcp__chat__send_message(content="...") for the current conversation. Do not use send_message(target=...) to move work to another same-agent surface; use handoff_to_target first. Do NOT output text directly. ---');
293
+ }
294
+ return toText('No new messages.');
295
+ }
296
+ catch (err) {
297
+ return toText(`Error: ${err.message}`);
298
+ }
299
+ });
300
+ // ── list_server ───────────────────────────────────────────────────────────────
301
+ server.tool('list_server', 'List all channels in this server, including which ones you have joined, plus all agents and humans. Use this to discover who and where you can message.', {}, async () => {
302
+ try {
303
+ const { ok, data } = await apiFetch('/server');
304
+ if (!ok)
305
+ return toText(`Error: ${errText(data, 'server list failed')}`);
306
+ const d = data;
307
+ let text = '## Server\n\n';
308
+ text += '### Channels\n';
309
+ text += "Use `#channel-name` with send_message to post in a channel. `joined` means you currently belong to that channel.\n";
310
+ if (d.channels?.length) {
311
+ for (const ch of d.channels) {
312
+ const status = ch.joined ? 'joined' : 'not joined';
313
+ text += ch.description
314
+ ? ` - #${ch.name} [${status}] — ${ch.description}\n`
315
+ : ` - #${ch.name} [${status}]\n`;
316
+ }
317
+ }
318
+ else {
319
+ text += ' (none)\n';
320
+ }
321
+ text += '\n### Agents\n';
322
+ text += 'Other AI agents in this server.\n';
323
+ if (d.agents?.length) {
324
+ for (const a of d.agents)
325
+ text += ` - @${a.name} (${a.status})\n`;
326
+ }
327
+ else {
328
+ text += ' (none)\n';
329
+ }
330
+ text += '\n### Humans\n';
331
+ text += 'To start a new DM: send_message(target="dm:@name"). To reply in an existing DM: reuse the target from received messages.\n';
332
+ if (d.humans?.length) {
333
+ for (const u of d.humans)
334
+ text += ` - @${u.name}\n`;
335
+ }
336
+ else {
337
+ text += ' (none)\n';
338
+ }
339
+ return toText(text);
340
+ }
341
+ catch (err) {
342
+ return toText(`Error: ${err.message}`);
343
+ }
344
+ });
345
+ // ── get_self_state ───────────────────────────────────────────────────────────
346
+ server.tool('get_self_state', 'Return your global work overview across DM/channel/thread surfaces: current surface, other active surfaces, unread/queued work, recent tasks, and recent handoffs.', {
347
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. Prefer text. json/both return a compact structured summary, not the full raw state dump.'),
348
+ }, async ({ format }) => {
349
+ try {
350
+ const params = new URLSearchParams();
351
+ params.set('conversationId', conversationId);
352
+ const { ok, data } = await apiFetch(`/self-state?${params.toString()}`);
353
+ if (!ok)
354
+ return toText(`Error: ${errText(data, 'self-state fetch failed')}`);
355
+ const d = data;
356
+ return toText(formatToolResult(format, formatSelfStateToolText(d), buildCompactSelfStateData(d)));
357
+ }
358
+ catch (err) {
359
+ return toText(`Error: ${err.message}`);
360
+ }
361
+ });
362
+ // ── get_runtime_presence ─────────────────────────────────────────────────────
363
+ server.tool('get_runtime_presence', 'Return node / host / runtime facts for this agent, including current host state, node online/offline status, runtime drivers, and capability inventory.', {
364
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. Prefer text. json/both return a compact structured summary.'),
365
+ }, async ({ format }) => {
366
+ try {
367
+ const params = new URLSearchParams();
368
+ params.set('conversationId', conversationId);
369
+ const { ok, data } = await apiFetch(`/runtime-presence?${params.toString()}`);
370
+ if (!ok)
371
+ return toText(`Error: ${errText(data, 'runtime-presence fetch failed')}`);
372
+ return toText(formatToolResult(format, formatRuntimePresenceToolText(data), buildCompactRuntimePresenceData(data)));
373
+ }
374
+ catch (err) {
375
+ return toText(`Error: ${err.message}`);
376
+ }
377
+ });
378
+ // ── inspect_workspace ───────────────────────────────────────────────────────
379
+ server.tool('inspect_workspace', 'Inspect the current agent workspace root. Returns git / directory facts for the current workspace only.', {
380
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. Prefer text. json/both return structured inspect data.'),
381
+ }, async ({ format }) => {
382
+ try {
383
+ const { ok, data } = await apiFetch('/workspace-inspect');
384
+ if (!ok)
385
+ return toText(`Error: ${errText(data, 'workspace inspect failed')}`);
386
+ const d = data;
387
+ return toText(formatToolResult(format, formatWorkspaceInspectToolText(d), buildCompactWorkspaceInspectData(d)));
388
+ }
389
+ catch (err) {
390
+ return toText(`Error: ${err.message}`);
391
+ }
392
+ });
393
+ // ── list_workspace_tree ─────────────────────────────────────────────────────
394
+ server.tool('list_workspace_tree', 'List directory entries from the current agent workspace. This is read-only and scoped to the current workspace root.', {
395
+ path: z.string().optional().describe('Optional relative directory path inside the current workspace. Defaults to the workspace root.'),
396
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. Prefer text. json/both return the structured tree result.'),
397
+ }, async ({ path, format }) => {
398
+ try {
399
+ const params = new URLSearchParams();
400
+ if (path?.trim())
401
+ params.set('path', path.trim());
402
+ const qs = params.toString();
403
+ const { ok, data } = await apiFetch(`/workspace-tree${qs ? `?${qs}` : ''}`);
404
+ if (!ok)
405
+ return toText(`Error: ${errText(data, 'workspace tree fetch failed')}`);
406
+ const d = data;
407
+ return toText(formatToolResult(format, formatWorkspaceTreeToolText(d), d));
408
+ }
409
+ catch (err) {
410
+ return toText(`Error: ${err.message}`);
411
+ }
412
+ });
413
+ // ── read_workspace_file ─────────────────────────────────────────────────────
414
+ server.tool('read_workspace_file', 'Read a text file from the current agent workspace. This is read-only and scoped to the current workspace root.', {
415
+ path: z.string().trim().min(1, 'path is required').describe('Relative file path inside the current workspace.'),
416
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. text returns the content with a short header, json returns the structured payload, both returns both.'),
417
+ }, async ({ path, format }) => {
418
+ try {
419
+ const params = new URLSearchParams();
420
+ params.set('path', path.trim());
421
+ const { ok, data } = await apiFetch(`/workspace-file?${params.toString()}`);
422
+ if (!ok)
423
+ return toText(`Error: ${errText(data, 'workspace file fetch failed')}`);
424
+ const d = data;
425
+ return toText(formatToolResult(format, formatWorkspaceFileToolText(d), d));
426
+ }
427
+ catch (err) {
428
+ return toText(`Error: ${err.message}`);
429
+ }
430
+ });
431
+ // ── list_my_conversations ────────────────────────────────────────────────────
432
+ server.tool('list_my_conversations', 'Bulk-discover your known DM/channel/thread work surfaces with current status, unread count, queued prompts, and a compact structured summary.', {
433
+ status: z
434
+ .enum(['all', 'idle', 'queued', 'active', 'recovering', 'awaiting_approval', 'failed'])
435
+ .default('all')
436
+ .describe('Optional status filter (default: all)'),
437
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. text returns a concise report, json returns the full structured payload, both returns both.'),
438
+ }, async ({ status, format }) => {
439
+ try {
440
+ const params = new URLSearchParams();
441
+ if (status !== 'all')
442
+ params.set('status', status);
443
+ const { ok, data } = await apiFetch(`/my-conversations?${params.toString()}`);
444
+ if (!ok)
445
+ return toText(`Error: ${errText(data, 'my-conversations fetch failed')}`);
446
+ const d = data;
447
+ if (!d.conversations?.length) {
448
+ return toText(`No${status !== 'all' ? ` ${status}` : ''} conversations found.`);
449
+ }
450
+ return toText(formatToolResult(format, formatConversationListText(d.conversations), data));
451
+ }
452
+ catch (err) {
453
+ return toText(`Error: ${err.message}`);
454
+ }
455
+ });
456
+ // ── get_conversation_summary ────────────────────────────────────────────────
457
+ server.tool('get_conversation_summary', 'Return one-surface detail for a known work surface. Query by target such as "dm:@oldpan" or "#channel:threadid", or by conversation_id if you already know it.', {
458
+ target: z.string().optional().describe('Optional target to summarize, for example "dm:@oldpan" or "#all:abcd1234".'),
459
+ conversation_id: z.string().optional().describe('Optional conversation ID to summarize.'),
460
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. text returns a concise report, json returns the full structured payload, both returns both.'),
461
+ }, async ({ target, conversation_id, format }) => {
462
+ try {
463
+ if (!target?.trim() && !conversation_id?.trim()) {
464
+ return toText('Error: target or conversation_id is required');
465
+ }
466
+ const params = new URLSearchParams();
467
+ if (target?.trim())
468
+ params.set('target', target.trim());
469
+ if (conversation_id?.trim())
470
+ params.set('conversation_id', conversation_id.trim());
471
+ const { ok, data } = await apiFetch(`/conversation-summary?${params.toString()}`);
472
+ if (!ok)
473
+ return toText(`Error: ${errText(data, 'conversation summary fetch failed')}`);
474
+ const d = data;
475
+ if (!d.conversation)
476
+ return toText('Error: conversation summary unavailable');
477
+ return toText(formatToolResult(format, formatConversationSummaryToolText(d.conversation), d.conversation));
478
+ }
479
+ catch (err) {
480
+ return toText(`Error: ${err.message}`);
481
+ }
482
+ });
483
+ // ── get_relevant_context_bundle ─────────────────────────────────────────────
484
+ server.tool('get_relevant_context_bundle', 'Return a ranked shortlist of the most relevant neighboring surfaces, tasks, and handoffs for the current conversation. This is a narrowing tool, not the canonical state source.', {
485
+ max_surfaces: z.number().default(4).describe('Maximum number of related surfaces to include (default 4, max 8).'),
486
+ max_tasks: z.number().default(3).describe('Maximum number of related tasks to include (default 3, max 8).'),
487
+ max_handoffs: z.number().default(3).describe('Maximum number of related handoffs to include (default 3, max 8).'),
488
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. text returns a concise ranked bundle, json returns the structured payload, both returns both.'),
489
+ }, async ({ max_surfaces, max_tasks, max_handoffs, format }) => {
490
+ try {
491
+ const { ok, data } = await apiFetch('/context-bundle', {
492
+ method: 'POST',
493
+ body: {
494
+ conversationId,
495
+ maxSurfaces: max_surfaces,
496
+ maxTasks: max_tasks,
497
+ maxHandoffs: max_handoffs,
498
+ format,
499
+ },
500
+ });
501
+ if (!ok)
502
+ return toText(`Error: ${errText(data, 'context bundle fetch failed')}`);
503
+ const d = data;
504
+ if (!d.bundle)
505
+ return toText('Error: context bundle unavailable');
506
+ return toText(formatToolResult(format, formatContextBundleToolText(d.bundle), d.bundle));
507
+ }
508
+ catch (err) {
509
+ return toText(`Error: ${err.message}`);
510
+ }
511
+ });
512
+ // ── handoff_to_target ───────────────────────────────────────────────────────
513
+ server.tool('handoff_to_target', 'Create a same-agent cross-surface handoff to another target such as a DM, channel root, or thread. Use continue_there to move detailed execution there, or delegate_only / collab to keep the current surface active.', {
514
+ target: z.string().trim().min(1).describe('Target surface to hand off to, for example "#proj", "#proj:abcd1234", "dm:@oldpan", or "dm:@oldpan:abcd1234".'),
515
+ mode: z.enum(['delegate_only', 'continue_there', 'collab']).default('delegate_only').describe('Handoff mode.'),
516
+ goal: z.string().trim().min(1).describe('Primary goal for the target surface.'),
517
+ why_now: z.string().optional().describe('Optional short explanation of why the handoff is happening now.'),
518
+ constraints: z.array(z.string()).optional().describe('Optional constraints for the work.'),
519
+ already_done: z.array(z.string()).optional().describe('Optional bullet list of work already completed.'),
520
+ expected_output: z.string().optional().describe('Optional expected output or done condition.'),
521
+ related_task_ref: z.string().optional().describe('Optional related task ref such as "task_xxx" or "#t12".'),
522
+ optional_refs: z.array(z.string()).optional().describe('Optional channels/DMs/tasks to consult if needed.'),
523
+ }, async ({ target, mode, goal, why_now, constraints, already_done, expected_output, related_task_ref, optional_refs, }) => {
524
+ try {
525
+ const { ok, data } = await apiFetch('/handoff', {
526
+ method: 'POST',
527
+ body: {
528
+ conversationId,
529
+ target: target.trim(),
530
+ mode,
531
+ goal: goal.trim(),
532
+ context: {
533
+ ...(why_now?.trim() ? { why_now: why_now.trim() } : {}),
534
+ ...(constraints?.length ? { constraints } : {}),
535
+ ...(already_done?.length ? { already_done } : {}),
536
+ ...(expected_output?.trim() ? { expected_output: expected_output.trim() } : {}),
537
+ ...(related_task_ref?.trim() ? { related_task_ref: related_task_ref.trim() } : {}),
538
+ ...(optional_refs?.length ? { optional_refs } : {}),
539
+ },
540
+ },
541
+ });
542
+ if (!ok)
543
+ return toText(`Error: ${errText(data, 'handoff failed')}`);
544
+ const d = data;
545
+ if (!d.handoff)
546
+ return toText('Error: handoff failed');
547
+ const statusLine = d.handoff.reportText?.trim()
548
+ || `${d.handoff.mode} [${d.handoff.status}] -> ${d.handoff.targetReplyTarget}`;
549
+ const queueLine = d.handoff.queued ? 'Target surface was queued.' : 'Target surface was dispatched immediately.';
550
+ const continueNote = d.handoff.mode === 'continue_there'
551
+ ? '\n\nDetailed execution should now continue on the target surface. End work on the current surface here.'
552
+ : '';
553
+ const cancelLine = d.handoff.cancelRequested
554
+ ? `\nSource run cancel requested (${d.handoff.cancelRunId ?? 'run id unavailable'}).`
555
+ : '';
556
+ const errorLine = d.handoff.error ? `\nError detail: ${d.handoff.error}` : '';
557
+ return toText(`Started handoff ${d.handoff.handoffId}.\n${statusLine}\n${queueLine}${cancelLine}${errorLine}${continueNote}`);
558
+ }
559
+ catch (err) {
560
+ return toText(`Error: ${err.message}`);
561
+ }
562
+ });
563
+ // ── read_history ──────────────────────────────────────────────────────────────
564
+ server.tool('read_history', "Read message history for a channel, DM, or thread. Use the same target format: '#channel', 'dm:@name', '#channel:id' for threads. Supports pagination with 'before' / 'after' and centered context jumps with 'around' (messageId or seq).", {
565
+ channel: z.string().describe("The target to read history from — e.g. '#general', 'dm:@alice', '#general:abcd1234ef567890'"),
566
+ limit: z.number().default(50).describe('Max number of messages to return (default 50, max 100)'),
567
+ around: z.union([z.string(), z.number()]).optional().describe('Center the history window around a messageId prefix or seq in this exact target.'),
568
+ before: z.number().optional().describe('Return messages before this seq number (backward pagination).'),
569
+ after: z.number().optional().describe('Return messages after this seq number (catching up on unread).'),
570
+ include_root: z.boolean().optional().describe('When reading a thread target, optionally prepend the thread root message for context.'),
571
+ }, async ({ channel, limit, around, before, after, include_root }) => {
572
+ try {
573
+ const params = new URLSearchParams();
574
+ params.set('channel', channel);
575
+ params.set('limit', String(Math.min(limit, 100)));
576
+ if (around !== undefined)
577
+ params.set('around', String(around));
578
+ if (before !== undefined)
579
+ params.set('before', String(before));
580
+ if (after !== undefined)
581
+ params.set('after', String(after));
582
+ if (include_root)
583
+ params.set('include_root', 'true');
584
+ params.set('conversationId', conversationId);
585
+ const activeRunId = currentRunId();
586
+ if (activeRunId)
587
+ params.set('runId', activeRunId);
588
+ const { ok, data } = await apiFetch(`/history?${params}`);
589
+ if (!ok)
590
+ return toText(`Error: ${errText(data, 'history fetch failed')}`);
591
+ const d = data;
592
+ if (!d.messages?.length)
593
+ return toText('No messages in this channel.');
594
+ const formatted = formatHistoryMessages(d.messages);
595
+ let footer = '';
596
+ if (around !== undefined && d.messages.length > 0 && (d.has_older || d.has_newer)) {
597
+ const minSeq = d.messages[0].seq;
598
+ const maxSeq = d.messages[d.messages.length - 1].seq;
599
+ footer = `\n\n--- Context window shown. Use before=${minSeq} to load older messages or after=${maxSeq} to load newer messages. ---`;
600
+ }
601
+ else if (d.has_more && d.messages.length > 0) {
602
+ if (after !== undefined) {
603
+ const maxSeq = d.messages[d.messages.length - 1].seq;
604
+ footer = `\n\n--- ${d.messages.length} messages shown. Use after=${maxSeq} to load more recent messages. ---`;
605
+ }
606
+ else {
607
+ const minSeq = d.messages[0].seq;
608
+ footer = `\n\n--- ${d.messages.length} messages shown. Use before=${minSeq} to load older messages. ---`;
609
+ }
610
+ }
611
+ const aroundHeader = around !== undefined ? ` around ${String(around)}` : '';
612
+ return toText(`## Message History for ${channel}${aroundHeader} (${d.messages.length} messages)\n\n${formatted}\n\n--- IMPORTANT: The [Message metadata] block is system metadata for routing and context. Do NOT quote or repeat it back to the user. ---${footer}`);
613
+ }
614
+ catch (err) {
615
+ return toText(`Error: ${err.message}`);
616
+ }
617
+ });
618
+ server.tool('get_handoff_status', 'Look up one same-agent handoff by handoff_id and return its current status, source/target, and preserved context.', {
619
+ handoff_id: z.string().trim().min(1, 'handoff_id is required').describe('The handoff id returned by handoff_to_target.'),
620
+ format: z.enum(['text', 'json', 'both']).default('text').describe('Output format. text returns a concise report, json returns the full structured payload, both returns both.'),
621
+ }, async ({ handoff_id, format }) => {
622
+ try {
623
+ const { ok, data } = await apiFetch(`/handoffs/${encodeURIComponent(handoff_id.trim())}`);
624
+ if (!ok)
625
+ return toText(`Error: ${errText(data, 'handoff status lookup failed')}`);
626
+ const d = data;
627
+ if (!d.handoff)
628
+ return toText('Error: handoff not found');
629
+ const lines = [
630
+ `Handoff: ${d.handoff.handoffId}`,
631
+ `Status: ${d.handoff.status}`,
632
+ `Mode: ${d.handoff.mode}`,
633
+ `Target: ${d.handoff.targetReplyTarget}`,
634
+ ];
635
+ if (d.handoff.sourceReplyTarget)
636
+ lines.push(`Source: ${d.handoff.sourceReplyTarget}`);
637
+ if (d.handoff.reportText?.trim())
638
+ lines.push('', d.handoff.reportText.trim());
639
+ return toText(formatToolResult(format, lines.join('\n'), d.handoff));
640
+ }
641
+ catch (err) {
642
+ return toText(`Error: ${err.message}`);
643
+ }
644
+ });
645
+ // ── search_messages ──────────────────────────────────────────────────────────
646
+ server.tool('search_messages', 'Search messages visible to this agent. Use this to find relevant older context, then inspect a hit with read_history(channel="<target>", around="<messageId>").', {
647
+ query: z.string().describe('Search query'),
648
+ channel: z.string().optional().describe("Optional target to scope the search, e.g. '#general', 'dm:@alice', '#general:abcd1234ef567890'"),
649
+ limit: z.number().default(10).describe('Max number of search results to return (default 10, max 20)'),
650
+ }, async ({ query, channel, limit }) => {
651
+ try {
652
+ const trimmed = query.trim();
653
+ if (!trimmed)
654
+ return toText('Search query cannot be empty.');
655
+ const params = new URLSearchParams();
656
+ params.set('q', trimmed);
657
+ params.set('limit', String(Math.min(limit, 20)));
658
+ if (channel)
659
+ params.set('channel', channel);
660
+ const { ok, data } = await apiFetch(`/search?${params}`);
661
+ if (!ok)
662
+ return toText(`Error: ${errText(data, 'search failed')}`);
663
+ const d = data;
664
+ if (!d.results?.length)
665
+ return toText('No search results.');
666
+ const formatted = d.results.map((result, index) => [
667
+ `[${index + 1}] msg=${result.id} seq=${result.seq} time=${formatBeijingPromptTimestamp(result.createdAt)}`,
668
+ `target: ${result.target}`,
669
+ `sender: @${result.senderName}${result.senderType === 'agent' ? ' (agent)' : ''}`,
670
+ `content: ${result.content}`,
671
+ `match: ${result.snippet}`,
672
+ `next: read_history(channel="${result.target}", around="${result.id}", limit=20)`,
673
+ ].join('\n')).join('\n\n');
674
+ return toText(`## Search Results for "${trimmed}" (${d.results.length} results)\n\n${formatted}`);
675
+ }
676
+ catch (err) {
677
+ return toText(`Error: ${err.message}`);
678
+ }
679
+ });
680
+ // ── list_tasks ────────────────────────────────────────────────────────────────
681
+ server.tool('list_tasks', "List task-messages on a channel's board. Returns each task-message with its local board number (#t1, #t2...), stable global task ref, title, status, assignee, and message root when available.", {
682
+ channel: z.string().describe("The channel whose task board to view — e.g. '#general'"),
683
+ status: z
684
+ .enum(['all', 'todo', 'in_progress', 'in_review', 'done'])
685
+ .default('all')
686
+ .describe('Filter by status (default: all)'),
687
+ }, async ({ channel, status }) => {
688
+ try {
689
+ const params = new URLSearchParams({ channel });
690
+ if (status !== 'all')
691
+ params.set('status', status);
692
+ const { ok, data } = await apiFetch(`/tasks?${params}`);
693
+ if (!ok)
694
+ return toText(`Error: ${errText(data, 'list tasks failed')}`);
695
+ const d = data;
696
+ if (!d.tasks?.length) {
697
+ return toText(`No${status !== 'all' ? ` ${status}` : ''} tasks in ${channel}.`);
698
+ }
699
+ const formatted = d.tasks.map((t) => {
700
+ const assignee = t.claimedByName ? ` → @${t.claimedByName}` : '';
701
+ const creator = t.createdByName ? ` (by @${t.createdByName})` : '';
702
+ const msgShort = t.messageId ? t.messageId.slice(0, 8) : null;
703
+ const msgTag = msgShort ? ` msg=${msgShort}` : '';
704
+ const descLine = t.description
705
+ ? `\n desc: ${t.description.length > 80 ? t.description.slice(0, 80) + '...' : t.description}`
706
+ : '';
707
+ return `${formatTaskIdentity(t.agentTaskRef, t.taskNumber)} [${t.status}] "${t.title}"${assignee}${creator}${msgTag}${descLine}`;
708
+ }).join('\n');
709
+ const threadHints = d.tasks
710
+ .filter((t) => t.messageId)
711
+ .map((t) => `#t${t.taskNumber} → send_message to "${channel}:${buildThreadShortId(t.messageId)}"`)
712
+ .join('\n');
713
+ const hint = threadHints ? `\n\nTo reply in a task's thread:\n${threadHints}` : '';
714
+ return toText(`## Task Board for ${channel} (${d.tasks.length} tasks)\n\n${formatted}${hint}`);
715
+ }
716
+ catch (err) {
717
+ return toText(`Error: ${err.message}`);
718
+ }
719
+ });
720
+ server.tool('list_my_tasks', 'Bulk-rediscover your own tasks across DMs and channels. By default this returns tasks you created or have been assigned to, and includes each task\'s stable global task ref for later lookup.', {
721
+ status: z
722
+ .enum(['all', 'todo', 'in_progress', 'in_review', 'done'])
723
+ .default('all')
724
+ .describe('Filter by status (default: all)'),
725
+ scope: z
726
+ .enum(['all', 'dm', 'channel'])
727
+ .default('all')
728
+ .describe('Filter to DM tasks, channel tasks, or both (default: all)'),
729
+ }, async ({ status, scope }) => {
730
+ try {
731
+ const params = new URLSearchParams();
732
+ if (status !== 'all')
733
+ params.set('status', status);
734
+ if (scope !== 'all')
735
+ params.set('scope', scope);
736
+ const { ok, data } = await apiFetch(`/my-tasks?${params}`);
737
+ if (!ok)
738
+ return toText(`Error: ${errText(data, 'list my tasks failed')}`);
739
+ const d = data;
740
+ if (!d.tasks?.length) {
741
+ return toText(`No${status !== 'all' ? ` ${status}` : ''} ${scope !== 'all' ? scope : ''} tasks found for you.`.replace(/\s+/g, ' ').trim());
742
+ }
743
+ const formatted = d.tasks.map((t) => {
744
+ const identity = formatTaskIdentity(t.agentTaskRef, t.taskNumber);
745
+ const assignee = t.claimedByName ? ` → @${t.claimedByName}` : '';
746
+ const msgTag = t.messageId ? ` msg=${t.messageId.slice(0, 8)}` : '';
747
+ const threadTag = t.threadTarget ? ` thread=${t.threadTarget}` : '';
748
+ return `${identity} [${t.status}] "${t.title}"${assignee} source=${t.sourceLabel ?? t.sourceTarget ?? t.channelId}${msgTag}${threadTag}`;
749
+ }).join('\n');
750
+ return toText(`## My Tasks (${d.tasks.length} tasks)\n\n${formatted}`);
751
+ }
752
+ catch (err) {
753
+ return toText(`Error: ${err.message}`);
754
+ }
755
+ });
756
+ server.tool('get_task_status', 'Look up one known task by its stable global task ref and return its current status, source, assignee, and thread hint.', {
757
+ task_ref: z.string().trim().min(1, 'task_ref is required').describe('Stable global task ref, for example task_ab12cd34ef56'),
758
+ }, async ({ task_ref }) => {
759
+ try {
760
+ const params = new URLSearchParams({ task_ref: task_ref.trim().toLowerCase() });
761
+ const { ok, data } = await apiFetch(`/tasks/by-ref?${params}`);
762
+ if (!ok)
763
+ return toText(`Error: ${errText(data, 'task status lookup failed')}`);
764
+ const d = data;
765
+ if (d.deletedTask) {
766
+ const task = d.deletedTask;
767
+ const identity = formatTaskIdentity(task.agentTaskRef, task.taskNumber);
768
+ const details = [
769
+ `Task: ${identity}`,
770
+ `Title: ${task.title}`,
771
+ 'Status: deleted',
772
+ `Previous status: ${task.status}`,
773
+ `Source: ${task.sourceLabel ?? task.sourceTarget ?? task.channelId}`,
774
+ `Deleted: ${task.deletedAt ? formatBeijingPromptTimestamp(task.deletedAt) : 'unknown'}`,
775
+ ];
776
+ if (task.deletedByName)
777
+ details.push(`Deleted by: @${task.deletedByName}`);
778
+ return toText(details.join('\n'));
779
+ }
780
+ const task = d.task;
781
+ if (!task)
782
+ return toText(`Error: task ${task_ref} not found`);
783
+ const identity = formatTaskIdentity(task.agentTaskRef, task.taskNumber);
784
+ const threadTarget = task.threadTarget ?? buildThreadTarget(task.sourceTarget, task.messageId ?? null);
785
+ const details = [
786
+ `Task: ${identity}`,
787
+ `Title: ${task.title}`,
788
+ `Status: ${task.status}`,
789
+ `Source: ${task.sourceLabel ?? task.sourceTarget ?? task.channelId}`,
790
+ `Assignee: ${task.claimedByName ? `@${task.claimedByName}` : 'unclaimed'}`,
791
+ `Creator: ${task.createdByName ? `@${task.createdByName}` : 'unknown'}`,
792
+ `Updated: ${task.updatedAt ? formatBeijingPromptTimestamp(task.updatedAt) : 'unknown'}`,
793
+ ];
794
+ if (task.messageId)
795
+ details.push(`Root message: ${task.messageId}`);
796
+ if (threadTarget)
797
+ details.push(`Thread target: ${threadTarget}`);
798
+ return toText(details.join('\n'));
799
+ }
800
+ catch (err) {
801
+ return toText(`Error: ${err.message}`);
802
+ }
803
+ });
804
+ server.tool('get_task_history', 'Look up one task you claimed or participated in by its stable global task ref and return its structured lifecycle history. Events are returned newest first. Prefer get_task_status when you only need the current state. Use read_history when you need the actual thread discussion or work output.', {
805
+ task_ref: z.string().trim().min(1, 'task_ref is required').describe('Stable global task ref, for example task_ab12cd34ef56'),
806
+ limit: z.number().default(50).describe('Maximum number of history events to return (default 50, max 200)'),
807
+ }, async ({ task_ref, limit }) => {
808
+ try {
809
+ const params = new URLSearchParams({
810
+ task_ref: task_ref.trim().toLowerCase(),
811
+ limit: String(Math.min(limit, 200)),
812
+ });
813
+ const { ok, data } = await apiFetch(`/tasks/history?${params}`);
814
+ if (!ok)
815
+ return toText(`Error: ${errText(data, 'task history lookup failed')}`);
816
+ const d = data;
817
+ if (d.deletedTask) {
818
+ const task = d.deletedTask;
819
+ const identity = formatTaskIdentity(task.agentTaskRef, task.taskNumber);
820
+ const header = [
821
+ `Task: ${identity}`,
822
+ `Title: ${task.title}`,
823
+ 'Status: deleted',
824
+ `Previous status: ${task.status}`,
825
+ `Source: ${task.sourceLabel ?? task.sourceTarget ?? task.channelId}`,
826
+ ];
827
+ if (task.deletedAt)
828
+ header.push(`Deleted: ${formatBeijingPromptTimestamp(task.deletedAt)}`);
829
+ const timeline = d.events?.length
830
+ ? d.events.map((event) => {
831
+ const parts = [
832
+ formatBeijingPromptTimestamp(event.createdAt),
833
+ event.type,
834
+ ];
835
+ if (event.actorName)
836
+ parts.push(event.actorType === 'system' ? event.actorName : `@${event.actorName}`);
837
+ if (event.fromStatus || event.toStatus)
838
+ parts.push(`status ${event.fromStatus ?? 'unknown'} → ${event.toStatus ?? 'unknown'}`);
839
+ if (event.claimedByNameAfter)
840
+ parts.push(`assignee @${event.claimedByNameAfter}`);
841
+ if (event.threadTarget)
842
+ parts.push(`thread ${event.threadTarget}`);
843
+ return `- ${parts.join(' · ')}`;
844
+ }).join('\n')
845
+ : '- No structured task events recorded yet.';
846
+ return toText(`${header.join('\n')}\nOrder: newest first\nAccess: deleted task tombstone for tasks you claimed or participated in.\n\n## History\n${timeline}`);
847
+ }
848
+ const task = d.task;
849
+ if (!task)
850
+ return toText(`Error: task ${task_ref} not found`);
851
+ const identity = formatTaskIdentity(task.agentTaskRef, task.taskNumber);
852
+ const header = [
853
+ `Task: ${identity}`,
854
+ `Title: ${task.title}`,
855
+ `Status: ${task.status}`,
856
+ `Source: ${task.sourceLabel ?? task.sourceTarget ?? task.channelId}`,
857
+ ];
858
+ const timeline = d.events?.length
859
+ ? d.events.map((event) => {
860
+ const parts = [
861
+ formatBeijingPromptTimestamp(event.createdAt),
862
+ event.type,
863
+ ];
864
+ if (event.actorName)
865
+ parts.push(event.actorType === 'system' ? event.actorName : `@${event.actorName}`);
866
+ if (event.fromStatus || event.toStatus)
867
+ parts.push(`status ${event.fromStatus ?? 'unknown'} → ${event.toStatus ?? 'unknown'}`);
868
+ if (event.claimedByNameAfter)
869
+ parts.push(`assignee @${event.claimedByNameAfter}`);
870
+ if (event.threadTarget)
871
+ parts.push(`thread ${event.threadTarget}`);
872
+ return `- ${parts.join(' · ')}`;
873
+ }).join('\n')
874
+ : '- No structured task events recorded yet.';
875
+ return toText(`${header.join('\n')}\nOrder: newest first\nAccess: only tasks you claimed or participated in are visible here.\n\n## History\n${timeline}`);
876
+ }
877
+ catch (err) {
878
+ return toText(`Error: ${err.message}`);
879
+ }
880
+ });
881
+ // ── create_tasks ──────────────────────────────────────────────────────────────
882
+ server.tool('create_tasks', "Create one or more new task-messages in a top-level channel or DM. Use this only for genuinely new work or subtasks, not to convert an existing message — use claim_tasks with message_ids for that. In a primary DM, default to a direct reply. In that context, only use this when the user explicitly wants task tracking or the request clearly needs multi-step tracked work. If a primary-DM task is created, the platform opens the task thread automatically and mirrors lifecycle status in the main DM.", {
883
+ channel: z.string().describe("The channel or DM to create tasks in — e.g. '#general' or 'dm:@User'"),
884
+ tasks: z
885
+ .array(z.object({
886
+ title: z.string().describe('Task title'),
887
+ description: z.string().trim().min(1, 'description is required').describe('Required task brief / goal / done criteria'),
888
+ }))
889
+ .describe('Array of tasks to create'),
890
+ collaborator_names: z.array(z.string()).optional().describe('Optional collaborator agent names to pull into the same shared task-thread. The current agent remains the owner.'),
891
+ }, async ({ channel, tasks, collaborator_names }) => {
892
+ try {
893
+ const { ok, data } = await apiFetch('/tasks', {
894
+ method: 'POST',
895
+ body: { channel, tasks, collaborator_names, conversationId },
896
+ });
897
+ if (!ok)
898
+ return toText(`Error: ${errText(data, 'create tasks failed')}`);
899
+ const d = data;
900
+ const created = d.tasks?.map((t) => {
901
+ const msgShort = t.messageId ? t.messageId.slice(0, 8) : null;
902
+ const handoff = t.handoffError
903
+ ? ` → handoff failed: ${t.handoffError}`
904
+ : t.handoffStarted && t.threadTarget
905
+ ? ` → handoff started in ${t.threadTarget}`
906
+ : '';
907
+ const identity = formatTaskIdentity(t.agentTaskRef, t.taskNumber);
908
+ return msgShort ? `${identity} msg=${msgShort} "${t.title}"${handoff}` : `${identity} "${t.title}"${handoff}`;
909
+ }).join('\n') ?? '';
910
+ const hasHandoff = d.tasks?.some((t) => t.handoffStarted) ?? false;
911
+ const threadHints = hasHandoff ? '' : d.tasks
912
+ ?.filter((t) => t.messageId)
913
+ .map((t) => `#t${t.taskNumber} → send_message to "${channel}:${buildThreadShortId(t.messageId)}"`)
914
+ .join('\n') ?? '';
915
+ const hint = threadHints ? `\n\nTo follow up in each task's thread:\n${threadHints}` : '';
916
+ const deliverySection = formatTaskUpdateDeliveriesSection((d.tasks ?? []).flatMap((t) => t.delivery ? [t.delivery] : []));
917
+ const handoffNote = hasHandoff
918
+ ? '\n\nPrimary DM handoff started automatically. End this main-DM turn here. Detailed work continues in the task thread. This is an expected phase transition, not a failure or interruption. Do not manually send any follow-up in the main DM; the platform will mirror task status there.'
919
+ : '';
920
+ return toText(`Created ${d.tasks?.length ?? 0} task(s) in ${channel}:\n${created}${deliverySection}${hint}${handoffNote}`);
921
+ }
922
+ catch (err) {
923
+ return toText(`Error: ${err.message}`);
924
+ }
925
+ });
926
+ // ── claim_message ─────────────────────────────────────────────────────────────
927
+ server.tool('claim_message', `[DEPRECATED] Use claim_tasks instead. Compatibility alias for claim_tasks(message_ids=[...]). Promote one or more existing top-level channel or DM messages into task-messages and claim them. Use the 8-character msg= ID from received messages or read_history. In the current primary DM, you may use message_ids=["current"] for the latest user request instead of manually picking an older msg id. In a primary DM, default to a direct reply. In that context, only use this when the user explicitly wants task tracking or the request clearly needs multi-step tracked work. Each promoted message becomes the task root and default thread. If a primary-DM task is claimed, the platform hands it off to the task thread automatically and mirrors lifecycle status in the main DM. If a message is already a task-message, the claim fails. Thread messages cannot be converted. The task brief is required; use separate calls when promoted messages need different briefs.`, {
928
+ channel: z.string().describe("The channel or DM — e.g. '#engineering' or 'dm:@User'"),
929
+ message_ids: z.array(z.string()).describe("8-char message IDs (the msg= value from check_messages or read_history, e.g. ['a1b2c3d4']). In the current primary DM you may use ['current'] to claim the latest user request."),
930
+ title: z.string().optional().describe('Optional task title override. If omitted, uses the message content (truncated to 120 chars).'),
931
+ description: z.string().trim().min(1, 'description is required').describe('Required task brief / goal / done criteria. Use one call per message when briefs differ.'),
932
+ collaborator_names: z.array(z.string()).optional().describe('Optional collaborator agent names to pull into the same shared task-thread. The current agent remains the owner.'),
933
+ }, async ({ channel, message_ids, title, description, collaborator_names }) => {
934
+ try {
935
+ const { ok, data } = await apiFetch('/tasks/claim', {
936
+ method: 'POST',
937
+ body: { channel, message_ids, title, description, collaborator_names, conversationId },
938
+ });
939
+ if (!ok)
940
+ return toText(`Error: ${errText(data, 'claim-message failed')}`);
941
+ const d = data;
942
+ const lines = (d.results ?? []).map((r) => {
943
+ const msgShort = r.messageId.slice(0, 8);
944
+ if (r.success) {
945
+ const identity = formatTaskIdentity(r.agentTaskRef, r.taskNumber);
946
+ if (r.handoffError)
947
+ return `msg:${msgShort} → ${identity}: claimed, handoff failed — ${r.handoffError}`;
948
+ if (r.handoffStarted && r.threadTarget)
949
+ return `msg:${msgShort} → ${identity}: claimed, handoff started in ${r.threadTarget}`;
950
+ return `msg:${msgShort} → ${identity}: claimed`;
951
+ }
952
+ return `msg:${msgShort}: FAILED — ${r.reason ?? 'unknown error'}`;
953
+ });
954
+ const succeeded = (d.results ?? []).filter((r) => r.success).length;
955
+ const failed = (d.results ?? []).length - succeeded;
956
+ let summary = `${succeeded} claimed`;
957
+ if (failed > 0)
958
+ summary += `, ${failed} failed`;
959
+ const contextBlocks = (d.results ?? [])
960
+ .filter((r) => r.success && r.context?.length)
961
+ .map((r) => {
962
+ const msgs = r.context.map((m) => ` @${m.senderName}: ${m.content.length > 100 ? m.content.slice(0, 100) + '...' : m.content}`).join('\n');
963
+ return `${formatTaskIdentity(r.agentTaskRef, r.taskNumber)} context:\n${msgs}`;
964
+ }).join('\n\n');
965
+ const contextSection = contextBlocks ? `\n\n${contextBlocks}` : '';
966
+ const hasHandoff = (d.results ?? []).some((r) => r.handoffStarted);
967
+ const threadHints = hasHandoff ? '' : (d.results ?? [])
968
+ .filter((r) => r.success)
969
+ .map((r) => `${formatTaskIdentity(r.agentTaskRef, r.taskNumber)} → send_message to "${channel}:${buildThreadShortId(r.messageId)}"`)
970
+ .join('\n');
971
+ const hint = threadHints ? `\n\nFollow up in each task's thread:\n${threadHints}` : '';
972
+ const deliverySection = formatTaskUpdateDeliveriesSection((d.results ?? []).flatMap((r) => r.delivery ? [r.delivery] : []));
973
+ const handoffNote = hasHandoff
974
+ ? '\n\nPrimary DM handoff started automatically. End this main-DM turn here. Detailed work continues in the task thread. This is an expected phase transition, not a failure or interruption. Do not manually send any follow-up in the main DM; the platform will mirror task status there.'
975
+ : '';
976
+ return toText(`Claim results (${summary}):\n${lines.join('\n')}${contextSection}${deliverySection}${hint}${handoffNote}`);
977
+ }
978
+ catch (err) {
979
+ return toText(`Error: ${err.message}`);
980
+ }
981
+ });
982
+ // ── update_task_details ───────────────────────────────────────────────────────
983
+ server.tool('update_task_details', 'Update a task title and brief. Use this when the task goal, scope, or done criteria need to be clarified after creation.', {
984
+ channel: z.string().describe("The channel — e.g. '#general'"),
985
+ task_number: z.number().describe('The task number to update (e.g. 3)'),
986
+ title: z.string().trim().min(1, 'title is required').describe('Updated task title'),
987
+ description: z.string().trim().min(1, 'description is required').describe('Updated task brief / goal / done criteria'),
988
+ }, async ({ channel, task_number, title, description }) => {
989
+ try {
990
+ const { ok, data } = await apiFetch('/tasks/update-details', {
991
+ method: 'POST',
992
+ body: { channel, task_number, title, description, conversationId },
993
+ });
994
+ if (!ok)
995
+ return toText(`Error: ${errText(data, 'update task details failed')}`);
996
+ const d = data;
997
+ if (d.delivery)
998
+ return toText(formatTaskUpdateDeliveryText(d.delivery));
999
+ return toText(`#t${task_number} details updated.`);
1000
+ }
1001
+ catch (err) {
1002
+ return toText(`Error: ${err.message}`);
1003
+ }
1004
+ });
1005
+ // ── claim_tasks ───────────────────────────────────────────────────────────────
1006
+ server.tool('claim_tasks', `Claim tasks so you are assigned to work on them. Two modes:
1007
+ 1. By task number: claim existing tasks shown in list_tasks. Use task_numbers=[1, 3].
1008
+ 2. By message ID: convert a regular top-level channel or DM message into a task and claim it. Use message_ids=["a1b2c3d4"] with description="goal and done criteria". In the current primary DM, prefer message_ids=["current"] for the latest user request.
1009
+
1010
+ Thread messages cannot be claimed or converted into tasks. If a task is in "todo" status, claiming auto-advances it to "in_progress". If another agent already claimed it, the claim fails — do not work on that task, move on. In a primary DM, default to a direct reply. In that context, only claim there when the user explicitly wants task tracking or the request clearly needs multi-step tracked work. In a primary DM, a successful claim is handed off to the task thread automatically; stop the current run and let the thread continue the work. Claim before starting trackable execution work.`, {
1011
+ channel: z.string().describe("The channel or DM whose tasks to claim — e.g. '#general' or 'dm:@User'"),
1012
+ task_numbers: z.array(z.number()).optional().describe('Task numbers to claim (e.g. [1, 3, 5])'),
1013
+ message_ids: z.array(z.string()).optional().describe("Message IDs or short ID prefixes (the 8-char msg= value, e.g. ['a1b2c3d4']). In the current primary DM you may use ['current'] to claim the latest user request. Converts a regular top-level message to a task and claims it. Thread messages are not allowed."),
1014
+ title: z.string().optional().describe('Optional task title override when claiming regular top-level messages by message ID.'),
1015
+ description: z.string().optional().describe('Required task brief / goal / done criteria when claiming regular top-level messages by message ID.'),
1016
+ collaborator_names: z.array(z.string()).optional().describe('Optional collaborator agent names to pull into the same shared task-thread. The current agent remains the owner. Supported for claim_message and for claim_tasks when using message_ids or a single existing channel task_number.'),
1017
+ }, async ({ channel, task_numbers, message_ids, title, description, collaborator_names }) => {
1018
+ try {
1019
+ if ((!task_numbers || task_numbers.length === 0) && (!message_ids || message_ids.length === 0)) {
1020
+ return toText('Error: provide at least one of task_numbers or message_ids');
1021
+ }
1022
+ const { ok, data } = await apiFetch('/tasks/claim', {
1023
+ method: 'POST',
1024
+ body: { channel, task_numbers, message_ids, title, description, collaborator_names, conversationId },
1025
+ });
1026
+ if (!ok)
1027
+ return toText(`Error: ${errText(data, 'claim tasks failed')}`);
1028
+ const d = data;
1029
+ const lines = (d.results ?? []).map((r) => {
1030
+ const identity = formatTaskIdentity(r.agentTaskRef, r.taskNumber);
1031
+ if (!r.success)
1032
+ return `${identity}: FAILED — ${r.reason ?? 'already claimed'}`;
1033
+ if (r.handoffError)
1034
+ return `${identity}: claimed, handoff failed — ${r.handoffError}`;
1035
+ if (r.handoffStarted && r.threadTarget)
1036
+ return `${identity}: claimed, handoff started in ${r.threadTarget}`;
1037
+ return `${identity}: claimed`;
1038
+ });
1039
+ const succeeded = (d.results ?? []).filter((r) => r.success).length;
1040
+ const failed = (d.results ?? []).length - succeeded;
1041
+ let summary = `${succeeded} claimed`;
1042
+ if (failed > 0)
1043
+ summary += `, ${failed} failed`;
1044
+ const contextBlocks = (d.results ?? [])
1045
+ .filter((r) => r.success && r.context?.length)
1046
+ .map((r) => {
1047
+ const msgs = r.context.map((m) => ` @${m.senderName}: ${m.content.length > 100 ? m.content.slice(0, 100) + '...' : m.content}`).join('\n');
1048
+ return `${formatTaskIdentity(r.agentTaskRef, r.taskNumber)} context:\n${msgs}`;
1049
+ }).join('\n\n');
1050
+ const contextSection = contextBlocks ? `\n\n${contextBlocks}` : '';
1051
+ const hasHandoff = (d.results ?? []).some((r) => r.handoffStarted);
1052
+ const threadHints = hasHandoff ? '' : (d.results ?? [])
1053
+ .filter((r) => r.success && r.messageId)
1054
+ .map((r) => `${formatTaskIdentity(r.agentTaskRef, r.taskNumber)} → send_message to "${channel}:${buildThreadShortId(r.messageId)}"`)
1055
+ .join('\n');
1056
+ const hint = threadHints ? `\n\nFollow up in each task's thread:\n${threadHints}` : '';
1057
+ const deliverySection = formatTaskUpdateDeliveriesSection((d.results ?? []).flatMap((r) => r.delivery ? [r.delivery] : []));
1058
+ const handoffNote = hasHandoff
1059
+ ? '\n\nPrimary DM handoff started automatically. End this main-DM turn here. Detailed work continues in the task thread. This is an expected phase transition, not a failure or interruption. Do not manually send any follow-up in the main DM; the platform will mirror task status there.'
1060
+ : '';
1061
+ return toText(`Claim results (${summary}):\n${lines.join('\n')}${contextSection}${deliverySection}${hint}${handoffNote}`);
1062
+ }
1063
+ catch (err) {
1064
+ return toText(`Error: ${err.message}`);
1065
+ }
1066
+ });
1067
+ // ── unclaim_task ──────────────────────────────────────────────────────────────
1068
+ server.tool('unclaim_task', 'Release your claim on a task so someone else can pick it up. Only use this if you can no longer work on the task — not as a way to mark it done.', {
1069
+ channel: z.string().describe("The channel — e.g. '#general'"),
1070
+ task_number: z.number().describe('The task number to unclaim (e.g. 3)'),
1071
+ }, async ({ channel, task_number }) => {
1072
+ try {
1073
+ const { ok, data } = await apiFetch('/tasks/unclaim', {
1074
+ method: 'POST',
1075
+ body: { channel, task_number, conversationId },
1076
+ });
1077
+ if (!ok)
1078
+ return toText(`Error: ${errText(data, 'unclaim failed')}`);
1079
+ const d = data;
1080
+ if (d.delivery)
1081
+ return toText(formatTaskUpdateDeliveryText(d.delivery));
1082
+ return toText(`#t${task_number} unclaimed — now open.`);
1083
+ }
1084
+ catch (err) {
1085
+ return toText(`Error: ${err.message}`);
1086
+ }
1087
+ });
1088
+ // ── update_task_status ────────────────────────────────────────────────────────
1089
+ server.tool('update_task_status', 'Update a task\'s progress status. Valid transitions for agents: todo→in_progress, in_progress→in_review, in_review→in_progress. Agents cannot mark a task done; move ordinary tasks to in_review when the work is ready for human validation, and let a human decide done or send it back. For one-time reminder task-threads, prefer submit_reminder_occurrence_for_review so the linked reminder occurrence also moves into awaiting_review.', {
1090
+ channel: z.string().describe("The channel — e.g. '#general'"),
1091
+ task_number: z.number().describe('The task number to update (e.g. 3)'),
1092
+ status: z
1093
+ .enum(['todo', 'in_progress', 'in_review'])
1094
+ .describe('The new status'),
1095
+ }, async ({ channel, task_number, status }) => {
1096
+ try {
1097
+ const { ok, data } = await apiFetch('/tasks/update-status', {
1098
+ method: 'POST',
1099
+ body: { channel, task_number, status, conversationId },
1100
+ });
1101
+ if (!ok)
1102
+ return toText(`Error: ${errText(data, 'update status failed')}`);
1103
+ const d = data;
1104
+ if (d.delivery)
1105
+ return toText(formatTaskUpdateDeliveryText(d.delivery));
1106
+ return toText(`#t${task_number} moved to ${status}.`);
1107
+ }
1108
+ catch (err) {
1109
+ return toText(`Error: ${err.message}`);
1110
+ }
1111
+ });
1112
+ // ── reminders ────────────────────────────────────────────────────────────────
1113
+ const REMINDER_ID_HINT = 'Reminder ID is an opaque system ID. Reminder title is not reminder_id; if you only know the title, call list_my_reminders first. Never invent placeholder reminder_id values such as dummy, dummy2, test, or a title string.';
1114
+ const OCCURRENCE_ID_HINT = 'Occurrence ID is an opaque system ID. If you do not already have it, call get_current_reminder_occurrence or list_reminder_occurrences first. Never invent placeholder occurrence_id values such as dummy, dummy2, or test.';
1115
+ const REMINDER_TRUST_HINT = 'Treat successful reminder tool results as authoritative. Reminder mutations are live user-visible state. Do not create placeholder, probe, throwaway, or scratch reminders just to test whether reminder tools work.';
1116
+ server.tool('list_my_reminders', `List reminders bound to the current agent and current DM user scope. All times are interpreted and displayed in Asia/Shanghai. Recurring reminders create a new DM task-thread on each trigger. ${REMINDER_ID_HINT} ${REMINDER_TRUST_HINT}`, {}, async () => {
1117
+ try {
1118
+ const params = new URLSearchParams();
1119
+ params.set('conversationId', conversationId);
1120
+ const { ok, data } = await apiFetch(`/reminders?${params.toString()}`);
1121
+ if (!ok)
1122
+ return toText(`Error: ${errText(data, 'list reminders failed')}`);
1123
+ const reminders = (data.reminders ?? []);
1124
+ if (reminders.length === 0)
1125
+ return toText('No reminders in the current DM scope.');
1126
+ const lines = reminders.map((reminder) => {
1127
+ const reminderId = String(reminder.reminderId ?? '');
1128
+ const title = String(reminder.title ?? 'untitled');
1129
+ const status = String(reminder.status ?? 'unknown');
1130
+ const scheduleKind = String(reminder.scheduleKind ?? 'one_time');
1131
+ const intervalUnit = reminder.intervalUnit ? String(reminder.intervalUnit) : null;
1132
+ const intervalValue = typeof reminder.intervalValue === 'number' ? reminder.intervalValue : null;
1133
+ const nextRunAt = typeof reminder.nextRunAt === 'number' ? reminder.nextRunAt : null;
1134
+ const recentOccurrences = Array.isArray(reminder.recentOccurrences) ? reminder.recentOccurrences : [];
1135
+ const openOccurrence = recentOccurrences.find((occurrence) => {
1136
+ const occurrenceStatus = String(occurrence.status ?? '');
1137
+ return occurrenceStatus === 'pending' || occurrenceStatus === 'dispatched' || occurrenceStatus === 'failed' || occurrenceStatus === 'snoozed';
1138
+ });
1139
+ const schedule = scheduleKind === 'recurring'
1140
+ ? `every ${intervalValue ?? '?'} ${intervalUnit ?? ''}`.trim()
1141
+ : 'once';
1142
+ const nextRun = nextRunAt ? formatBeijingPromptTimestamp(new Date(nextRunAt).toISOString()) : 'none';
1143
+ const openLine = openOccurrence
1144
+ ? ` open_occurrence_id=${String(openOccurrence.occurrenceId ?? '')} ${String(openOccurrence.status ?? '')}`
1145
+ : '';
1146
+ return `- reminder_id=${reminderId} title="${title}" [${status}] ${schedule} next=${nextRun}${openLine}`;
1147
+ }).join('\n');
1148
+ return toText(`Current reminders:\n${lines}`);
1149
+ }
1150
+ catch (err) {
1151
+ return toText(`Error: ${err.message}`);
1152
+ }
1153
+ });
1154
+ server.tool('create_reminder', `Create a reminder for the current agent in the current DM user scope. All times are Asia/Shanghai wall-clock times. Recurring reminders generate a fresh DM task-thread every time they trigger. This is a live user-visible mutation, not a scratchpad. The returned Reminder ID is the opaque ID you must reuse for update/run/pause/resume/cancel operations. Call this once per real reminder you intend to keep. After a successful create_reminder result, trust it and do not call create_reminder again for the same reminder unless the user explicitly asked for another one. ${REMINDER_TRUST_HINT}`, {
1155
+ title: z.string().trim().min(1, 'title is required').describe('Reminder title shown in the UI and reused as the DM task title.'),
1156
+ prompt_text: z.string().trim().min(1, 'prompt_text is required').describe('Task brief to execute whenever the reminder fires.'),
1157
+ schedule_kind: z.enum(['one_time', 'recurring']).describe('Reminder type.'),
1158
+ start_at: z.number().describe('First trigger time as unix milliseconds, interpreted in Asia/Shanghai when chosen by the caller.'),
1159
+ interval_unit: z.enum(['hour', 'day', 'week']).optional().describe('Required for recurring reminders.'),
1160
+ interval_value: z.number().optional().describe('Required for recurring reminders.'),
1161
+ end_at: z.number().optional().describe('Optional end time in unix milliseconds.'),
1162
+ max_occurrences: z.number().optional().describe('Optional cap for recurring reminders.'),
1163
+ }, async ({ title, prompt_text, schedule_kind, start_at, interval_unit, interval_value, end_at, max_occurrences }) => {
1164
+ try {
1165
+ const { ok, data } = await apiFetch('/reminders', {
1166
+ method: 'POST',
1167
+ body: {
1168
+ conversationId,
1169
+ title,
1170
+ promptText: prompt_text,
1171
+ scheduleKind: schedule_kind,
1172
+ startAt: start_at,
1173
+ intervalUnit: interval_unit,
1174
+ intervalValue: interval_value,
1175
+ endAt: end_at,
1176
+ maxOccurrences: max_occurrences,
1177
+ },
1178
+ });
1179
+ if (!ok)
1180
+ return toText(`Error: ${errText(data, 'create reminder failed')}`);
1181
+ const reminder = data;
1182
+ const nextRunAt = typeof reminder.nextRunAt === 'number' ? reminder.nextRunAt : null;
1183
+ const nextRun = nextRunAt ? formatBeijingPromptTimestamp(new Date(nextRunAt).toISOString()) : 'none';
1184
+ return toText([
1185
+ `Created reminder "${String(reminder.title ?? title)}".`,
1186
+ `Reminder ID: ${String(reminder.reminderId ?? '')}`,
1187
+ `Schedule: ${String(reminder.scheduleKind ?? schedule_kind)}`,
1188
+ `Next run: ${nextRun}`,
1189
+ 'Live reminder created and persisted. Reuse this Reminder ID for later actions instead of creating another reminder to verify the result.',
1190
+ ].join('\n'));
1191
+ }
1192
+ catch (err) {
1193
+ return toText(`Error: ${err.message}`);
1194
+ }
1195
+ });
1196
+ server.tool('update_reminder', `Update a reminder in the current DM user scope. All times are Asia/Shanghai wall-clock times. Recurring reminders still generate a fresh DM task-thread on each trigger. ${REMINDER_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1197
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1198
+ title: z.string().optional().describe('Optional new reminder title.'),
1199
+ prompt_text: z.string().optional().describe('Optional new reminder task brief.'),
1200
+ schedule_kind: z.enum(['one_time', 'recurring']).optional().describe('Optional new reminder type.'),
1201
+ start_at: z.number().optional().describe('Optional new start time as unix milliseconds.'),
1202
+ interval_unit: z.enum(['hour', 'day', 'week']).optional().describe('Optional new recurring interval unit.'),
1203
+ interval_value: z.number().optional().describe('Optional new recurring interval value.'),
1204
+ end_at: z.number().optional().describe('Optional new end time as unix milliseconds.'),
1205
+ max_occurrences: z.number().optional().describe('Optional new recurring cap.'),
1206
+ }, async ({ reminder_id, title, prompt_text, schedule_kind, start_at, interval_unit, interval_value, end_at, max_occurrences }) => {
1207
+ try {
1208
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}`, {
1209
+ method: 'PATCH',
1210
+ body: {
1211
+ conversationId,
1212
+ ...(title !== undefined ? { title } : {}),
1213
+ ...(prompt_text !== undefined ? { promptText: prompt_text } : {}),
1214
+ ...(schedule_kind !== undefined ? { scheduleKind: schedule_kind } : {}),
1215
+ ...(start_at !== undefined ? { startAt: start_at } : {}),
1216
+ ...(interval_unit !== undefined ? { intervalUnit: interval_unit } : {}),
1217
+ ...(interval_value !== undefined ? { intervalValue: interval_value } : {}),
1218
+ ...(end_at !== undefined ? { endAt: end_at } : {}),
1219
+ ...(max_occurrences !== undefined ? { maxOccurrences: max_occurrences } : {}),
1220
+ },
1221
+ });
1222
+ if (!ok)
1223
+ return toText(`Error: ${errText(data, 'update reminder failed')}`);
1224
+ const reminder = data;
1225
+ return toText(`Updated reminder "${String(reminder.title ?? reminder_id)}".`);
1226
+ }
1227
+ catch (err) {
1228
+ return toText(`Error: ${err.message}`);
1229
+ }
1230
+ });
1231
+ server.tool('pause_reminder', `Pause a reminder in the current DM user scope. While paused, it will not schedule new DM task-thread executions. ${REMINDER_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1232
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1233
+ }, async ({ reminder_id }) => {
1234
+ try {
1235
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}/pause`, {
1236
+ method: 'POST',
1237
+ body: { conversationId },
1238
+ });
1239
+ if (!ok)
1240
+ return toText(`Error: ${errText(data, 'pause reminder failed')}`);
1241
+ return toText(`Paused reminder ${reminder_id}.`);
1242
+ }
1243
+ catch (err) {
1244
+ return toText(`Error: ${err.message}`);
1245
+ }
1246
+ });
1247
+ server.tool('resume_reminder', `Resume a paused reminder in the current DM user scope. When active again, it resumes scheduling DM task-thread executions. ${REMINDER_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1248
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1249
+ }, async ({ reminder_id }) => {
1250
+ try {
1251
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}/resume`, {
1252
+ method: 'POST',
1253
+ body: { conversationId },
1254
+ });
1255
+ if (!ok)
1256
+ return toText(`Error: ${errText(data, 'resume reminder failed')}`);
1257
+ return toText(`Resumed reminder ${reminder_id}.`);
1258
+ }
1259
+ catch (err) {
1260
+ return toText(`Error: ${err.message}`);
1261
+ }
1262
+ });
1263
+ server.tool('cancel_reminder', `Cancel a reminder in the current DM user scope. This stops future scheduled runs for that reminder. ${REMINDER_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1264
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1265
+ }, async ({ reminder_id }) => {
1266
+ try {
1267
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}/cancel`, {
1268
+ method: 'POST',
1269
+ body: { conversationId },
1270
+ });
1271
+ if (!ok)
1272
+ return toText(`Error: ${errText(data, 'cancel reminder failed')}`);
1273
+ return toText(`Cancelled reminder ${reminder_id}.`);
1274
+ }
1275
+ catch (err) {
1276
+ return toText(`Error: ${err.message}`);
1277
+ }
1278
+ });
1279
+ server.tool('abandon_reminder', `Abandon a triggered one-time reminder in the current DM user scope. This cancels the reminder and closes the active reminder task thread without marking the linked task done. Use this when the user explicitly wants to stop a one-time reminder after it already started. ${REMINDER_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1280
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1281
+ }, async ({ reminder_id }) => {
1282
+ try {
1283
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}/abandon`, {
1284
+ method: 'POST',
1285
+ body: { conversationId },
1286
+ });
1287
+ if (!ok)
1288
+ return toText(`Error: ${errText(data, 'abandon reminder failed')}`);
1289
+ const payload = data;
1290
+ const taskNote = payload.task?.taskNumber != null
1291
+ ? ` Linked task #t${payload.task.taskNumber} remains ${String(payload.task.status ?? 'in_progress')}.`
1292
+ : '';
1293
+ return toText(`Abandoned reminder ${String(payload.reminder?.reminderId ?? reminder_id)}.${taskNote}`);
1294
+ }
1295
+ catch (err) {
1296
+ return toText(`Error: ${err.message}`);
1297
+ }
1298
+ });
1299
+ server.tool('list_reminder_occurrences', `List recent occurrences for one reminder in the current DM scope. Use this when you need exact occurrence IDs or status history. ${REMINDER_ID_HINT} ${OCCURRENCE_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1300
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1301
+ }, async ({ reminder_id }) => {
1302
+ try {
1303
+ const params = new URLSearchParams();
1304
+ params.set('conversationId', conversationId);
1305
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}/occurrences?${params.toString()}`);
1306
+ if (!ok)
1307
+ return toText(`Error: ${errText(data, 'list reminder occurrences failed')}`);
1308
+ const payload = data;
1309
+ const occurrences = payload.occurrences ?? [];
1310
+ if (occurrences.length === 0) {
1311
+ return toText(`Reminder ${reminder_id} has no occurrences yet.`);
1312
+ }
1313
+ const lines = occurrences.slice(0, 10).map((occurrence) => {
1314
+ const scheduledFor = typeof occurrence.scheduledFor === 'number'
1315
+ ? formatBeijingPromptTimestamp(new Date(occurrence.scheduledFor).toISOString())
1316
+ : 'unknown';
1317
+ const taskNote = occurrence.taskNumber != null ? ` task=#t${occurrence.taskNumber}` : '';
1318
+ return `- occurrence_id=${String(occurrence.occurrenceId ?? '')} ${String(occurrence.status ?? 'unknown')} at ${scheduledFor}${taskNote}`;
1319
+ }).join('\n');
1320
+ return toText(`Occurrences for "${String(payload.reminder?.title ?? reminder_id)}" (reminder_id=${String(payload.reminder?.reminderId ?? reminder_id)}):\n${lines}`);
1321
+ }
1322
+ catch (err) {
1323
+ return toText(`Error: ${err.message}`);
1324
+ }
1325
+ });
1326
+ server.tool('get_current_reminder_occurrence', `Get the current open occurrence and latest occurrence for a reminder in the current DM scope. Overdue is derived from the current open occurrence. ${REMINDER_ID_HINT} ${OCCURRENCE_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1327
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1328
+ }, async ({ reminder_id }) => {
1329
+ try {
1330
+ const params = new URLSearchParams();
1331
+ params.set('conversationId', conversationId);
1332
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}/current-occurrence?${params.toString()}`);
1333
+ if (!ok)
1334
+ return toText(`Error: ${errText(data, 'get current reminder occurrence failed')}`);
1335
+ const payload = data;
1336
+ const formatOccurrence = (label, occurrence) => {
1337
+ if (!occurrence)
1338
+ return `${label}: none`;
1339
+ const scheduledFor = typeof occurrence.scheduledFor === 'number'
1340
+ ? formatBeijingPromptTimestamp(new Date(occurrence.scheduledFor).toISOString())
1341
+ : 'unknown';
1342
+ return `${label}: occurrence_id=${String(occurrence.occurrenceId ?? '')} ${String(occurrence.status ?? 'unknown')} at ${scheduledFor}`;
1343
+ };
1344
+ return toText(`Reminder "${String(payload.reminder?.title ?? reminder_id)}" (reminder_id=${String(payload.reminder?.reminderId ?? reminder_id)})\n${formatOccurrence('Current', payload.currentOccurrence)}\n${formatOccurrence('Latest', payload.latestOccurrence)}`);
1345
+ }
1346
+ catch (err) {
1347
+ return toText(`Error: ${err.message}`);
1348
+ }
1349
+ });
1350
+ server.tool('submit_reminder_occurrence_for_review', `Submit a one-time reminder occurrence for user review. Use this when the reminder work is ready for approval but should not directly mark the linked DM task done. If you pass reminder_id instead of occurrence_id, the tool submits the current open occurrence for that reminder. ${REMINDER_ID_HINT} ${OCCURRENCE_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1351
+ occurrence_id: z.string().optional().describe(`Specific occurrence ID to submit for review. ${OCCURRENCE_ID_HINT}`),
1352
+ reminder_id: z.string().optional().describe(`Optional reminder ID; submits the current open occurrence for that reminder when occurrence_id is omitted. ${REMINDER_ID_HINT}`),
1353
+ }, async ({ occurrence_id, reminder_id }) => {
1354
+ try {
1355
+ if (!occurrence_id?.trim() && !reminder_id?.trim()) {
1356
+ return toText('Error: occurrence_id or reminder_id is required');
1357
+ }
1358
+ const path = occurrence_id?.trim()
1359
+ ? `/reminder-occurrences/${encodeURIComponent(occurrence_id.trim())}/submit-for-review`
1360
+ : `/reminders/${encodeURIComponent(reminder_id.trim())}/submit-current-for-review`;
1361
+ const { ok, data } = await apiFetch(path, {
1362
+ method: 'POST',
1363
+ body: { conversationId },
1364
+ });
1365
+ if (!ok)
1366
+ return toText(`Error: ${errText(data, 'submit reminder occurrence for review failed')}`);
1367
+ const payload = data;
1368
+ const occurrenceLabel = payload.occurrence?.occurrenceId ?? occurrence_id ?? reminder_id ?? '';
1369
+ const taskNote = payload.task?.taskNumber != null
1370
+ ? ` Linked task #t${payload.task.taskNumber} is now ${String(payload.task.status ?? 'in_review')}.`
1371
+ : '';
1372
+ return toText(`Submitted reminder occurrence ${occurrenceLabel} for review.${taskNote}`);
1373
+ }
1374
+ catch (err) {
1375
+ return toText(`Error: ${err.message}`);
1376
+ }
1377
+ });
1378
+ server.tool('complete_current_reminder_occurrence', `Complete the current open occurrence for a reminder in the current DM scope. This remains the recurring-reminder completion path. One-time reminders must use submit_reminder_occurrence_for_review instead. ${REMINDER_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1379
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1380
+ }, async ({ reminder_id }) => {
1381
+ try {
1382
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}/complete-current`, {
1383
+ method: 'POST',
1384
+ body: { conversationId },
1385
+ });
1386
+ if (!ok)
1387
+ return toText(`Error: ${errText(data, 'complete current reminder occurrence failed')}`);
1388
+ const payload = data;
1389
+ const taskNote = payload.task?.taskNumber != null ? ` Linked task #t${payload.task.taskNumber} is now done.` : '';
1390
+ return toText([
1391
+ `Reminder ID: ${reminder_id}`,
1392
+ `Completed occurrence ID: ${String(payload.occurrence?.occurrenceId ?? '')}`,
1393
+ `${taskNote.trim() || 'Linked task: none'}`,
1394
+ ].join('\n'));
1395
+ }
1396
+ catch (err) {
1397
+ return toText(`Error: ${err.message}`);
1398
+ }
1399
+ });
1400
+ server.tool('run_reminder_now', `Immediately create and dispatch one extra reminder occurrence in the current DM scope. This requires that the reminder has no other open occurrence, except a one-time reminder may use this to retry its current failed occurrence when that failure never created a linked task/thread. ${REMINDER_ID_HINT} Use the exact reminder_id from a reminder tool result; do not pass title text. ${REMINDER_TRUST_HINT}`, {
1401
+ reminder_id: z.string().trim().min(1, 'reminder_id is required').describe(REMINDER_ID_HINT),
1402
+ }, async ({ reminder_id }) => {
1403
+ try {
1404
+ const { ok, data } = await apiFetch(`/reminders/${encodeURIComponent(reminder_id)}/run-now`, {
1405
+ method: 'POST',
1406
+ body: { conversationId },
1407
+ });
1408
+ if (!ok)
1409
+ return toText(`Error: ${errText(data, 'run reminder now failed')}`);
1410
+ const payload = data;
1411
+ return toText([
1412
+ `Reminder ID: ${reminder_id}`,
1413
+ `Occurrence ID: ${String(payload.occurrence?.occurrenceId ?? '')}`,
1414
+ `Status: ${String(payload.occurrence?.status ?? '')}`,
1415
+ payload.task?.taskNumber != null ? `Task: #t${payload.task.taskNumber}` : 'Task: none',
1416
+ 'Run-now succeeded. Reuse this Reminder ID and Occurrence ID for follow-up actions instead of probing with extra reminder mutations.',
1417
+ ].join('\n'));
1418
+ }
1419
+ catch (err) {
1420
+ return toText(`Error: ${err.message}`);
1421
+ }
1422
+ });
1423
+ server.tool('skip_reminder_occurrence', `Skip an open reminder occurrence in the current DM scope. Use this when the current run should be ended without marking it complete. ${OCCURRENCE_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1424
+ occurrence_id: z.string().trim().min(1, 'occurrence_id is required').describe(OCCURRENCE_ID_HINT),
1425
+ reason: z.string().optional().describe('Optional short reason to store with the skip event.'),
1426
+ }, async ({ occurrence_id, reason }) => {
1427
+ try {
1428
+ const { ok, data } = await apiFetch(`/reminder-occurrences/${encodeURIComponent(occurrence_id)}/skip`, {
1429
+ method: 'POST',
1430
+ body: { conversationId, ...(reason?.trim() ? { reason: reason.trim() } : {}) },
1431
+ });
1432
+ if (!ok)
1433
+ return toText(`Error: ${errText(data, 'skip reminder occurrence failed')}`);
1434
+ const payload = data;
1435
+ return toText(`Skipped occurrence ${occurrence_id} (${String(payload.occurrence?.status ?? 'skipped')}).`);
1436
+ }
1437
+ catch (err) {
1438
+ return toText(`Error: ${err.message}`);
1439
+ }
1440
+ });
1441
+ server.tool('snooze_reminder_occurrence', `Snooze an open reminder occurrence in the current DM scope until a specific unix-millisecond timestamp. This keeps the same occurrence and does not change the long-term reminder cadence. ${OCCURRENCE_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1442
+ occurrence_id: z.string().trim().min(1, 'occurrence_id is required').describe(OCCURRENCE_ID_HINT),
1443
+ until: z.number().describe('Future unix timestamp in milliseconds, interpreted in Asia/Shanghai when chosen by the caller.'),
1444
+ }, async ({ occurrence_id, until }) => {
1445
+ try {
1446
+ const { ok, data } = await apiFetch(`/reminder-occurrences/${encodeURIComponent(occurrence_id)}/snooze`, {
1447
+ method: 'POST',
1448
+ body: { conversationId, until },
1449
+ });
1450
+ if (!ok)
1451
+ return toText(`Error: ${errText(data, 'snooze reminder occurrence failed')}`);
1452
+ const payload = data;
1453
+ const scheduledFor = typeof payload.occurrence?.scheduledFor === 'number'
1454
+ ? formatBeijingPromptTimestamp(new Date(payload.occurrence.scheduledFor).toISOString())
1455
+ : 'unknown';
1456
+ return toText(`Snoozed occurrence ${occurrence_id} until ${scheduledFor} (${String(payload.occurrence?.status ?? 'snoozed')}).`);
1457
+ }
1458
+ catch (err) {
1459
+ return toText(`Error: ${err.message}`);
1460
+ }
1461
+ });
1462
+ server.tool('complete_reminder_occurrence', `Explicitly complete a reminder occurrence. Completion is never automatic: use this after recurring reminder work is really done. One-time reminders must use submit_reminder_occurrence_for_review first and wait for user approval to reach done. If you pass reminder_id instead of occurrence_id, the tool completes the current open occurrence for that reminder. ${REMINDER_ID_HINT} ${OCCURRENCE_ID_HINT} ${REMINDER_TRUST_HINT}`, {
1463
+ occurrence_id: z.string().optional().describe(`Specific occurrence ID to complete. ${OCCURRENCE_ID_HINT}`),
1464
+ reminder_id: z.string().optional().describe(`Optional reminder ID; completes the current open occurrence for that reminder when occurrence_id is omitted. ${REMINDER_ID_HINT}`),
1465
+ }, async ({ occurrence_id, reminder_id }) => {
1466
+ try {
1467
+ if (!occurrence_id?.trim() && !reminder_id?.trim()) {
1468
+ return toText('Error: occurrence_id or reminder_id is required');
1469
+ }
1470
+ const path = occurrence_id?.trim()
1471
+ ? `/reminder-occurrences/${encodeURIComponent(occurrence_id.trim())}/complete`
1472
+ : `/reminders/${encodeURIComponent(reminder_id.trim())}/complete-current`;
1473
+ const { ok, data } = await apiFetch(path, {
1474
+ method: 'POST',
1475
+ body: { conversationId },
1476
+ });
1477
+ if (!ok)
1478
+ return toText(`Error: ${errText(data, 'complete reminder occurrence failed')}`);
1479
+ const payload = data;
1480
+ const occurrenceLabel = payload.occurrence?.occurrenceId ?? occurrence_id ?? reminder_id ?? '';
1481
+ const taskNote = payload.task?.taskNumber != null ? ` Linked task #t${payload.task.taskNumber} is now done.` : '';
1482
+ return toText(`Completed reminder occurrence ${occurrenceLabel}.${taskNote}`);
1483
+ }
1484
+ catch (err) {
1485
+ return toText(`Error: ${err.message}`);
1486
+ }
1487
+ });
1488
+ // ── upload_file ───────────────────────────────────────────────────────────────
1489
+ server.tool('upload_file', 'Upload a file (image, text, JSON, code, or binary; max 5MB) to the platform. Returns an attachment_id you can pass to send_message to attach it to a message.', {
1490
+ file_path: z.string().describe('Absolute path to the file on your local filesystem'),
1491
+ channel: z.string().optional().describe("Optional channel target override where this file will be used, such as '#general', '#general:threadid', or a raw channelId. If omitted, the current conversation scope is used."),
1492
+ }, async ({ file_path, channel }) => {
1493
+ let fileBuffer;
1494
+ try {
1495
+ fileBuffer = readFileSync(file_path);
1496
+ }
1497
+ catch {
1498
+ return toText(`Error: File not found or unreadable: ${file_path}`);
1499
+ }
1500
+ const ext = extname(file_path).toLowerCase();
1501
+ const mimeType = MIME_BY_EXTENSION[ext] ?? 'application/octet-stream';
1502
+ if (fileBuffer.length > 5 * 1024 * 1024)
1503
+ return toText('Error: File too large (max 5MB)');
1504
+ const filename = basename(file_path);
1505
+ const blob = new Blob([new Uint8Array(fileBuffer)], { type: mimeType });
1506
+ const form = new FormData();
1507
+ const channelOverride = channel?.trim();
1508
+ if (channelOverride) {
1509
+ form.append('channelId', channelOverride);
1510
+ }
1511
+ else {
1512
+ form.append('conversationId', conversationId);
1513
+ }
1514
+ form.append('file', blob, filename);
1515
+ const uploadHeaders = {};
1516
+ if (authToken)
1517
+ uploadHeaders['Authorization'] = `Bearer ${authToken}`;
1518
+ try {
1519
+ const res = await fetch(`${base}/assets/upload`, { method: 'POST', headers: uploadHeaders, body: form });
1520
+ const d = await res.json();
1521
+ if (!res.ok)
1522
+ return toText(`Error: ${d.error ?? 'upload failed'}`);
1523
+ return toText(`Uploaded: ${d.filename} (${(d.sizeBytes / 1024).toFixed(1)}KB)\n` +
1524
+ `Attachment ID: ${d.id}\n` +
1525
+ `Kind: ${String(d.kind ?? 'unknown')}\n\n` +
1526
+ `Pass this ID in send_message attachment_ids to attach it to a message.`);
1527
+ }
1528
+ catch (err) {
1529
+ return toText(`Error: ${err.message}`);
1530
+ }
1531
+ });
1532
+ // ── view_file ─────────────────────────────────────────────────────────────────
1533
+ server.tool('view_file', 'Download an attachment by its ID and view it directly. Returns an inline image preview for images, text for text-like files, and metadata for other binaries.', {
1534
+ attachment_id: z.string().describe('Attachment UUID returned by upload_file or shown in a message'),
1535
+ }, async ({ attachment_id }) => {
1536
+ const cacheRoot = resolveConversationAssetCacheRoot({
1537
+ explicitCacheRoot: assetCacheRootArg,
1538
+ agentId,
1539
+ conversationId,
1540
+ });
1541
+ const downloadHeaders = {};
1542
+ if (authToken)
1543
+ downloadHeaders['Authorization'] = `Bearer ${authToken}`;
1544
+ try {
1545
+ const metadataRes = await fetch(`${serverUrl}/api/assets/${attachment_id}/meta?agentId=${encodeURIComponent(agentId)}&conversationId=${encodeURIComponent(conversationId)}`, { headers: downloadHeaders });
1546
+ if (!metadataRes.ok)
1547
+ return toText(`Error: Failed to authorize attachment access (${metadataRes.status})`);
1548
+ const metadata = await metadataRes.json();
1549
+ const existing = findCachedAssetFile(cacheRoot, attachment_id);
1550
+ const cachedMetadata = readCachedAssetMetadata(cacheRoot, attachment_id);
1551
+ let fileBuffer;
1552
+ const fallbackFilename = metadata.filename?.trim() || cachedMetadata?.filename?.trim() || attachment_id;
1553
+ const fallbackMimeType = MIME_BY_EXTENSION[extname(fallbackFilename).toLowerCase()] ?? 'application/octet-stream';
1554
+ const mimeType = metadata.mimeType?.trim() || cachedMetadata?.mimeType?.trim() || fallbackMimeType;
1555
+ if (existing) {
1556
+ fileBuffer = Buffer.from(readFileSync(existing));
1557
+ if ((metadata.filename?.trim() || metadata.mimeType?.trim()) && (metadata.filename?.trim() !== cachedMetadata?.filename?.trim()
1558
+ || metadata.mimeType?.trim() !== cachedMetadata?.mimeType?.trim())) {
1559
+ writeCachedAssetMetadata(cacheRoot, attachment_id, metadata);
1560
+ }
1561
+ }
1562
+ else {
1563
+ const res = await fetch(`${serverUrl}/api/assets/${attachment_id}?agentId=${encodeURIComponent(agentId)}&conversationId=${encodeURIComponent(conversationId)}`, { headers: downloadHeaders });
1564
+ if (!res.ok)
1565
+ return toText(`Error: Failed to download attachment (${res.status})`);
1566
+ fileBuffer = Buffer.from(await res.arrayBuffer());
1567
+ writeFileSync(buildCachedAssetFilePath(cacheRoot, attachment_id, metadata.filename ?? attachment_id), fileBuffer);
1568
+ writeCachedAssetMetadata(cacheRoot, attachment_id, metadata);
1569
+ }
1570
+ const sizeLabel = `${(fileBuffer.length / 1024).toFixed(1)} KB`;
1571
+ if (mimeType.startsWith('image/')) {
1572
+ return {
1573
+ content: [
1574
+ { type: 'text', text: `Attachment ${attachment_id} (${mimeType}, ${sizeLabel}):` },
1575
+ { type: 'image', data: fileBuffer.toString('base64'), mimeType },
1576
+ ],
1577
+ };
1578
+ }
1579
+ if (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml') {
1580
+ return toText(buildInlineTextPreview({
1581
+ attachmentId: attachment_id,
1582
+ mimeType,
1583
+ sizeLabel,
1584
+ fileBuffer,
1585
+ }));
1586
+ }
1587
+ return toText(`Attachment ${attachment_id} (${mimeType}, ${sizeLabel}) downloaded. This binary type does not have an inline viewer.`);
1588
+ }
1589
+ catch (err) {
1590
+ return toText(`Error: ${err.message}`);
1591
+ }
1592
+ });
1593
+ // ─── list_ui_components ──────────────────────────────────────────────────────
1594
+ server.tool('list_ui_components', 'List the curated registry of available UI panel components. Each component has a name, human-readable description, data model, props schema, dataset schema, state schema, and supported actions. Treat this as the canonical current-run source for component-level props, dataset, state, and supported actions before the first render_panel call. In /panel:plan or /tool:plan, use this to prepare a chat wireframe and wait for confirmation; do not call render_panel or publish_workspace_tool in that planning run. Dataset source kinds currently accepted by render_panel are workspace_jsonl, inline_rows, attachment_manifest, query_handle, and api_jsonl. For detailed RowTemplateGrid template grammar, use the mounted ui-panel skill docs, including ComputedValue fixed row operations. In RowTemplateGrid, top-level panel.actions is an action registry; use ActionBar or ActionButton for explicit visible action controls. For dashboard/status/tool views, prefer RowTemplateGrid Columns plus concise field labels.', {}, async () => {
1595
+ try {
1596
+ const { ok, data } = await apiFetch('/ui-components');
1597
+ if (!ok)
1598
+ return toText(`Error: ${errText(data, 'list_ui_components failed')}`);
1599
+ const d = data;
1600
+ const contracts = (d.components ?? []);
1601
+ return toText(formatComponentRegistry(contracts));
1602
+ }
1603
+ catch (err) {
1604
+ return toText(`Error: ${err.message}`);
1605
+ }
1606
+ });
1607
+ // ─── render_panel ─────────────────────────────────────────────────────────────
1608
+ server.tool('render_panel', 'Create a new interactive UI panel from a curated component and a dataset source. Returns a panel_id that can be attached to messages via send_message(panel_ids=[...]). For continuing the same annotation/evaluation/workbench across turns, prefer upsert_panel(handle=...) instead of render_panel so the existing panel is updated in place. If you have not already read the exact component contract in this run, call list_ui_components() first. In /panel:plan, do not call this; send a chat wireframe and wait for confirmation first. For detailed RowTemplateGrid template grammar, consult the mounted ui-panel skill docs instead of guessing template nodes or dataset shape; ComputedValue supports only fixed row operations, not arbitrary formulas or JavaScript. Dataset source kinds include workspace_jsonl, inline_rows, attachment_manifest, query_handle, and api_jsonl; query_handle uses handle="workspace_jsonl:<path>" for one dynamic paged workspace JSONL file, or handle="workspace_jsonl_list:[\\"a.jsonl\\",\\"b.jsonl\\"]" for ordered multi-file JSONL aggregation. api_jsonl uses url:"http://localhost:.../rows.jsonl" or urls:[...] for one or more JSONL endpoints on the agent node, explicitly allowlisted remote origins, or explicitly allowlisted remote URL prefixes. api_jsonl auth uses node-side named auth profiles only; never put header values or tokens in panel payloads. In RowTemplateGrid, top-level panel.actions only declares actions; use ActionBar or ActionButton when those actions must be visibly clickable in the panel body. Use RowTemplateGrid Columns plus field labels for dashboard/status layouts. Use action mode="notify_agent" for agent-waking actions, or mode="platform_exec" with command and optional workspace-relative cwd for direct execution on the agent node. Panel-to-tool promotion can copy action hints toolKind, paramsSchema, persistent, maxRunSeconds, and idleTimeoutSeconds into the Workspace Tool manifest; ordinary panel clicks do not prompt for paramsSchema. Legacy rpcCommand/rpcCwd action input remains accepted for compatibility.', {
1609
+ component: z.string().trim().min(1, 'component is required').describe('Component name from the curated registry (e.g. "ImageReviewGrid").'),
1610
+ props: z.record(z.string(), z.unknown()).optional().describe('Optional props object matching the component\'s propsSchema.'),
1611
+ dataset: z.object({
1612
+ source: z.union([
1613
+ z.object({
1614
+ kind: z.literal('workspace_jsonl').describe('Dataset source kind: workspace_jsonl.'),
1615
+ path: z.string().trim().min(1, 'dataset.source.path is required').describe('Path to a JSONL file where each line is a dataset row matching the component\'s datasetSchema. Relative paths are resolved from the agent workspace; absolute paths are only allowed when the agent node config allowlists that root via panelAbsolutePathRoots.'),
1616
+ }),
1617
+ z.object({
1618
+ kind: z.literal('inline_rows').describe('Dataset source kind: inline_rows. For small datasets (< 1000 rows).'),
1619
+ rows: z.array(z.record(z.string(), z.unknown())).min(0).describe('Inline rows array. Each element is a row object with fields and media matching the component\'s datasetSchema. Maximum 1000 rows.'),
1620
+ }),
1621
+ z.object({
1622
+ kind: z.literal('attachment_manifest').describe('Dataset source kind: attachment_manifest. Reads an uploaded JSONL asset/attachment scoped to the current panel conversation.'),
1623
+ attachmentId: z.string().trim().min(1, 'dataset.source.attachmentId is required').optional().describe('Uploaded attachment ID containing JSONL rows.'),
1624
+ assetId: z.string().trim().min(1, 'dataset.source.assetId is required').optional().describe('Alias for attachmentId when using asset upload results.'),
1625
+ }).refine((value) => Boolean(value.attachmentId || value.assetId), {
1626
+ message: 'dataset.source.attachmentId or dataset.source.assetId is required',
1627
+ }),
1628
+ z.object({
1629
+ kind: z.literal('query_handle').describe('Dataset source kind: query_handle. Supports workspace_jsonl:<path> for one dynamic paged JSONL file or workspace_jsonl_list:["a.jsonl","b.jsonl"] for ordered multi-file JSONL aggregation without ingesting all rows into panel_rows.'),
1630
+ handle: z.string().trim().min(1, 'dataset.source.handle is required').describe('Query handle. Use workspace_jsonl:<path> for one JSONL file, or workspace_jsonl_list:["a.jsonl","b.jsonl"] for 1-16 JSONL files read in declaration order; relative paths resolve from the agent workspace, absolute paths are allowed only when the agent node config allowlists that root via panelAbsolutePathRoots.'),
1631
+ }),
1632
+ z.object({
1633
+ kind: z.literal('api_jsonl').describe('Dataset source kind: api_jsonl. Reads dynamic JSONL rows from one or more loopback HTTP endpoints on the agent node, agent-node allowlisted remote origins, or agent-node allowlisted URL prefixes without ingesting all rows into panel_rows.'),
1634
+ url: z.string().trim().min(1, 'dataset.source.url is required').optional().describe('Single HTTP URL returning JSONL rows matching the component dataset schema. Use either url or urls, not both.'),
1635
+ urls: z.array(z.string().trim().min(1)).min(1).max(16).optional().describe('Ordered list of 1-16 HTTP URLs returning JSONL rows. Rows are aggregated in URL order. Use either urls or url, not both.'),
1636
+ auth: z.object({
1637
+ profile: z.string().trim().regex(/^[A-Za-z0-9_.:-]{1,64}$/u, 'dataset.source.auth.profile must be a safe profile name'),
1638
+ }).optional().describe('Optional node-side auth profile name for authenticated api_jsonl endpoints. This is only a profile name advertised by the agent node; never include header names, tokens, or secrets here.'),
1639
+ }).refine((value) => Boolean(value.url) !== Boolean(value.urls), {
1640
+ message: 'dataset.source must specify exactly one of url or urls',
1641
+ }).describe('http://localhost, http://127.0.0.1, and http://[::1] are supported by default; remote http(s) endpoints require the agent node to advertise PANEL_API_JSONL_ALLOWED_ORIGINS or the narrower PANEL_API_JSONL_ALLOWED_URL_PREFIXES. Credentials and arbitrary unallowlisted remote URLs are rejected. Optional auth.profile must match an agent-node PANEL_API_JSONL_AUTH_PROFILES entry.'),
1642
+ ]).optional(),
1643
+ rows: z.string().trim().min(1, 'dataset.rows path is required').optional().describe('Legacy JSONL path. Relative paths are resolved from the agent workspace; absolute paths are only allowed when the agent node config allowlists that root via panelAbsolutePathRoots. Prefer dataset.source={kind:"workspace_jsonl", path:"..."}, dataset.source={kind:"inline_rows", rows:[...]}, dataset.source={kind:"attachment_manifest", attachmentId:"..."}, dataset.source={kind:"query_handle", handle:"workspace_jsonl:<path>"}, dataset.source={kind:"query_handle", handle:"workspace_jsonl_list:[\\"a.jsonl\\",\\"b.jsonl\\"]"}, or dataset.source={kind:"api_jsonl", url:"http://localhost:..."} for new calls.'),
1644
+ }).superRefine((value, ctx) => {
1645
+ const hasSource = value.source !== undefined;
1646
+ const hasLegacyRows = value.rows !== undefined;
1647
+ if (hasSource && hasLegacyRows) {
1648
+ ctx.addIssue({
1649
+ code: z.ZodIssueCode.custom,
1650
+ message: 'dataset.source and legacy dataset.rows are mutually exclusive',
1651
+ });
1652
+ }
1653
+ if (!hasSource && !hasLegacyRows) {
1654
+ ctx.addIssue({
1655
+ code: z.ZodIssueCode.custom,
1656
+ message: 'dataset.source or legacy dataset.rows is required',
1657
+ });
1658
+ }
1659
+ })
1660
+ .describe('Dataset source. Prefer source.kind="workspace_jsonl" for bounded workspace JSONL files, source.kind="inline_rows" for small datasets (< 1000 rows), source.kind="attachment_manifest" for uploaded JSONL assets, source.kind="query_handle" for dynamic paged workspace JSONL reads, or source.kind="api_jsonl" for dynamic HTTP JSONL reads from one or more loopback endpoints, explicitly allowlisted origins, or explicitly allowlisted URL prefixes; legacy rows path remains accepted for compatibility.'),
1661
+ actions: z.array(panelActionSchema).optional().describe('Optional panel actions that the user can trigger from the panel UI.'),
1662
+ }, async ({ component, props, dataset, actions }) => {
1663
+ try {
1664
+ const { ok, data } = await apiFetch('/panels', {
1665
+ method: 'POST',
1666
+ body: {
1667
+ component,
1668
+ props: props ?? {},
1669
+ dataset,
1670
+ actions: actions ?? [],
1671
+ conversationId,
1672
+ runId: currentRunId(),
1673
+ },
1674
+ });
1675
+ if (!ok) {
1676
+ return toText(formatRenderPanelError(data));
1677
+ }
1678
+ const d = data;
1679
+ const panelId = typeof d.panel_id === 'string' ? d.panel_id : '';
1680
+ if (!panelId) {
1681
+ return toText('Panel creation succeeded but no panel_id was returned. This is unexpected.');
1682
+ }
1683
+ return toText(formatRenderPanelSuccess(panelId, data));
1684
+ }
1685
+ catch (err) {
1686
+ return toText(`Error: ${err.message}`);
1687
+ }
1688
+ });
1689
+ // ─── upsert_panel ─────────────────────────────────────────────────────────────
1690
+ server.tool('upsert_panel', 'Create or update the same curated UI panel using a stable handle scoped to this user, agent, and conversation. Use this by default when continuing an existing annotation/evaluation/workbench panel across turns, instead of creating duplicates. Good handles are semantic and stable within the conversation, such as "pigai-eval-results", "image-review-batch", or "model-compare-workbench"; do not use random UUID handles unless this is intentionally one-off. The component must stay the same for an existing handle; use render_panel for explicit new panels. Dataset semantics match render_panel, and updates replace the target panel definition in place.', {
1691
+ handle: z.string().trim().min(1, 'handle is required').regex(/^[A-Za-z0-9_.:-]{1,96}$/u, 'handle must be 1-96 safe chars').describe('Stable semantic panel handle for this conversation, e.g. "pigai-eval-results", "image-review-batch", or "model-compare-workbench". Avoid random UUIDs unless the panel is intentionally one-off.'),
1692
+ expected_version: z.number().int().positive().optional().describe('Optional optimistic concurrency guard for an existing panel.'),
1693
+ component: z.string().trim().min(1, 'component is required').describe('Component name from the curated registry (e.g. "RowTemplateGrid"). Existing handles cannot switch component.'),
1694
+ props: z.record(z.string(), z.unknown()).optional().describe('Full props object for the desired panel state. On update this replaces prior props instead of shallow merging.'),
1695
+ dataset: z.object({
1696
+ source: z.union([
1697
+ z.object({
1698
+ kind: z.literal('workspace_jsonl'),
1699
+ path: z.string().trim().min(1, 'dataset.source.path is required'),
1700
+ }),
1701
+ z.object({
1702
+ kind: z.literal('inline_rows'),
1703
+ rows: z.array(z.record(z.string(), z.unknown())).min(0),
1704
+ }),
1705
+ z.object({
1706
+ kind: z.literal('attachment_manifest'),
1707
+ attachmentId: z.string().trim().min(1, 'dataset.source.attachmentId is required').optional(),
1708
+ assetId: z.string().trim().min(1, 'dataset.source.assetId is required').optional(),
1709
+ }).refine((value) => Boolean(value.attachmentId || value.assetId), {
1710
+ message: 'dataset.source.attachmentId or dataset.source.assetId is required',
1711
+ }),
1712
+ z.object({
1713
+ kind: z.literal('query_handle'),
1714
+ handle: z.string().trim().min(1, 'dataset.source.handle is required'),
1715
+ }),
1716
+ z.object({
1717
+ kind: z.literal('api_jsonl'),
1718
+ url: z.string().trim().min(1, 'dataset.source.url is required').optional(),
1719
+ urls: z.array(z.string().trim().min(1)).min(1).max(16).optional(),
1720
+ auth: z.object({
1721
+ profile: z.string().trim().regex(/^[A-Za-z0-9_.:-]{1,64}$/u, 'dataset.source.auth.profile must be a safe profile name'),
1722
+ }).optional(),
1723
+ }).refine((value) => Boolean(value.url) !== Boolean(value.urls), {
1724
+ message: 'dataset.source must specify exactly one of url or urls',
1725
+ }),
1726
+ ]).optional(),
1727
+ rows: z.string().trim().min(1, 'dataset.rows path is required').optional(),
1728
+ }).superRefine((value, ctx) => {
1729
+ const hasSource = value.source !== undefined;
1730
+ const hasLegacyRows = value.rows !== undefined;
1731
+ if (hasSource && hasLegacyRows) {
1732
+ ctx.addIssue({
1733
+ code: z.ZodIssueCode.custom,
1734
+ message: 'dataset.source and legacy dataset.rows are mutually exclusive',
1735
+ });
1736
+ }
1737
+ if (!hasSource && !hasLegacyRows) {
1738
+ ctx.addIssue({
1739
+ code: z.ZodIssueCode.custom,
1740
+ message: 'dataset.source or legacy dataset.rows is required',
1741
+ });
1742
+ }
1743
+ }).describe('Dataset source. Same accepted shape as render_panel.'),
1744
+ actions: z.array(panelActionSchema).optional().describe('Replacement action list for the desired panel state.'),
1745
+ }, async ({ handle, expected_version, component, props, dataset, actions }) => {
1746
+ try {
1747
+ const { ok, data } = await apiFetch('/panels/upsert', {
1748
+ method: 'POST',
1749
+ body: {
1750
+ handle,
1751
+ component,
1752
+ props: props ?? {},
1753
+ dataset,
1754
+ actions: actions ?? [],
1755
+ conversationId,
1756
+ runId: currentRunId(),
1757
+ ...(expected_version !== undefined ? { expectedVersion: expected_version } : {}),
1758
+ },
1759
+ });
1760
+ if (!ok) {
1761
+ return toText(formatUpsertPanelError(data));
1762
+ }
1763
+ const d = data;
1764
+ const panelId = typeof d.panel_id === 'string' ? d.panel_id : '';
1765
+ const version = typeof d.version === 'number' ? d.version : undefined;
1766
+ if (!panelId) {
1767
+ return toText('Panel upsert succeeded but no panel_id was returned. This is unexpected.');
1768
+ }
1769
+ const versionText = version ? ` version=${version}` : '';
1770
+ return toText(`Panel upserted. panel_id=${panelId} handle=${handle}${versionText}\n\nUse send_message(panel_ids=["${panelId}"]) to share it when needed, and use upsert_panel(handle="${handle}", ...) for future same-panel updates.`);
1771
+ }
1772
+ catch (err) {
1773
+ return toText(`Error: ${err.message}`);
1774
+ }
1775
+ });
1776
+ // ─── patch_panel ──────────────────────────────────────────────────────────────
1777
+ server.tool('patch_panel', 'Patch an existing UI panel in place without changing its curated component. Prefer this for precise follow-up refinements when you already know the panel_id. For recurring evaluation/annotation/workbench updates across turns, prefer upsert_panel(handle=...) so the agent does not create duplicates or depend on remembering panel_id. Supports shallow props patch, action replacement, row append/upsert/remove by rowId, status, progress, and result updates. Returns the new panel version. On version_conflict, read latest state/events/rows, merge user changes, and retry with currentVersion; do not blindly overwrite saved user state or annotations.', {
1778
+ panel_id: z.string().trim().min(1, 'panel_id is required').describe('Panel ID returned by render_panel.'),
1779
+ expected_version: z.number().int().positive().optional().describe('Optional optimistic concurrency guard. If supplied, the patch fails when the current panel version differs.'),
1780
+ component: z.string().trim().min(1).optional().describe('Optional current component name. Passing a different component is rejected; patch_panel cannot switch renderer classes.'),
1781
+ props_patch: z.record(z.string(), z.unknown()).optional().describe('Shallow patch merged into existing panel props.'),
1782
+ actions: z.array(panelActionSchema).optional().describe('Replacement action list for the panel.'),
1783
+ rows: z.object({
1784
+ append: z.array(z.object({
1785
+ rowId: z.string().trim().min(1).optional(),
1786
+ fields: z.record(z.string(), z.unknown()),
1787
+ media: z.record(z.string(), z.object({
1788
+ kind: z.enum(['workspace_path', 'asset']),
1789
+ value: z.string(),
1790
+ })).optional(),
1791
+ })).optional().describe('Rows appended at the end. Missing rowId is generated by the server.'),
1792
+ upsert: z.array(z.object({
1793
+ rowId: z.string().trim().min(1),
1794
+ fields: z.record(z.string(), z.unknown()),
1795
+ media: z.record(z.string(), z.object({
1796
+ kind: z.enum(['workspace_path', 'asset']),
1797
+ value: z.string(),
1798
+ })).optional(),
1799
+ })).optional().describe('Rows updated or inserted by rowId. Existing rows merge fields/media shallowly.'),
1800
+ remove_row_ids: z.array(z.string().trim().min(1)).optional().describe('Stable row IDs to remove. Remaining rows are reindexed.'),
1801
+ }).optional().describe('Row mutations keyed by stable rowId.'),
1802
+ status: z.enum(['idle', 'running', 'completed', 'failed']).optional().describe('Panel status.'),
1803
+ progress: z.record(z.string(), z.unknown()).nullable().optional().describe('Small structured progress object, or null to clear.'),
1804
+ result: z.record(z.string(), z.unknown()).nullable().optional().describe('Small structured result object, or null to clear.'),
1805
+ }, async ({ panel_id, expected_version, component, props_patch, actions, rows, status, progress, result }) => {
1806
+ try {
1807
+ const { ok, data } = await apiFetch(`/panels/${encodeURIComponent(panel_id)}`, {
1808
+ method: 'PATCH',
1809
+ body: {
1810
+ conversationId,
1811
+ ...(expected_version !== undefined ? { expectedVersion: expected_version } : {}),
1812
+ ...(component ? { component } : {}),
1813
+ ...(props_patch ? { props_patch } : {}),
1814
+ ...(actions ? { actions } : {}),
1815
+ ...(rows ? { rows } : {}),
1816
+ ...(status ? { status } : {}),
1817
+ ...(progress !== undefined ? { progress } : {}),
1818
+ ...(result !== undefined ? { result } : {}),
1819
+ },
1820
+ });
1821
+ if (!ok) {
1822
+ return toText(formatPatchPanelError(data));
1823
+ }
1824
+ return toText(formatPatchPanelSuccess(data));
1825
+ }
1826
+ catch (err) {
1827
+ return toText(`Error: ${err.message}`);
1828
+ }
1829
+ });
1830
+ // ─── read_panel_state ────────────────────────────────────────────────────────
1831
+ server.tool('read_panel_state', 'Read the current saved state of a panel (filters, sort, and selection) for the triggering user. Returns empty defaults if no state has been saved yet.', {
1832
+ panel_id: z.string().trim().min(1, 'panel_id is required').describe('The panel ID returned by render_panel.'),
1833
+ }, async ({ panel_id }) => {
1834
+ try {
1835
+ const { ok, data } = await apiFetch(`/panels/${encodeURIComponent(panel_id)}/state`);
1836
+ if (!ok) {
1837
+ if (data && typeof data === 'object' && 'error' in data) {
1838
+ return toText(`Error: ${String(data.error)}`);
1839
+ }
1840
+ return toText(`Error: ${errText(data, 'read_panel_state failed')}`);
1841
+ }
1842
+ const d = data;
1843
+ // For non-direct panels, API returns { perUser, shared, version } envelope.
1844
+ // For direct panels, API returns { state: {...} } wrapper.
1845
+ const state = ('perUser' in d || 'shared' in d) ? d : d.state;
1846
+ return toText(formatPanelState(state));
1847
+ }
1848
+ catch (err) {
1849
+ return toText(`Error: ${err.message}`);
1850
+ }
1851
+ });
1852
+ // ─── panel collaborators ────────────────────────────────────────────────────
1853
+ server.tool('list_panel_collaborators', 'List owner/collaborator agents registered for a panel surface. Use this before coordinating multi-agent panel maintenance.', {
1854
+ panel_id: z.string().trim().min(1, 'panel_id is required').describe('Panel ID returned by render_panel.'),
1855
+ }, async ({ panel_id }) => {
1856
+ try {
1857
+ const { ok, data } = await apiFetch(`/panels/${encodeURIComponent(panel_id)}/collaborators`);
1858
+ if (!ok)
1859
+ return toText(`Error: ${errText(data, 'list_panel_collaborators failed')}`);
1860
+ return toText(formatPanelCollaborators(data));
1861
+ }
1862
+ catch (err) {
1863
+ return toText(`Error: ${err.message}`);
1864
+ }
1865
+ });
1866
+ server.tool('add_panel_collaborator', 'Owner-only: add another agent as a collaborator on an existing panel surface so that agent can read/patch/delete and attach the panel in messages. This does not change human viewer visibility.', {
1867
+ panel_id: z.string().trim().min(1, 'panel_id is required').describe('Panel ID returned by render_panel.'),
1868
+ collaborator_agent_id: z.string().trim().min(1).optional().describe('Agent ID to add. Prefer this when known.'),
1869
+ collaborator_name: z.string().trim().min(1).optional().describe('Agent display name to add when the ID is not known.'),
1870
+ }, async ({ panel_id, collaborator_agent_id, collaborator_name }) => {
1871
+ try {
1872
+ const { ok, data } = await apiFetch(`/panels/${encodeURIComponent(panel_id)}/collaborators`, {
1873
+ method: 'POST',
1874
+ body: {
1875
+ ...(collaborator_agent_id ? { collaboratorAgentId: collaborator_agent_id } : {}),
1876
+ ...(collaborator_name ? { collaboratorName: collaborator_name } : {}),
1877
+ },
1878
+ });
1879
+ if (!ok)
1880
+ return toText(`Error: ${errText(data, 'add_panel_collaborator failed')}`);
1881
+ return toText(formatPanelCollaborators(data));
1882
+ }
1883
+ catch (err) {
1884
+ return toText(`Error: ${err.message}`);
1885
+ }
1886
+ });
1887
+ server.tool('remove_panel_collaborator', 'Owner-only: remove a non-owner collaborator agent from a panel surface. The owner row cannot be removed with this tool.', {
1888
+ panel_id: z.string().trim().min(1, 'panel_id is required').describe('Panel ID returned by render_panel.'),
1889
+ collaborator_agent_id: z.string().trim().min(1, 'collaborator_agent_id is required').describe('Agent ID to remove.'),
1890
+ }, async ({ panel_id, collaborator_agent_id }) => {
1891
+ try {
1892
+ const { ok, data } = await apiFetch(`/panels/${encodeURIComponent(panel_id)}/collaborators/${encodeURIComponent(collaborator_agent_id)}`, { method: 'DELETE' });
1893
+ if (!ok)
1894
+ return toText(`Error: ${errText(data, 'remove_panel_collaborator failed')}`);
1895
+ return toText(formatPanelCollaborators(data));
1896
+ }
1897
+ catch (err) {
1898
+ return toText(`Error: ${err.message}`);
1899
+ }
1900
+ });
1901
+ // ─── read_panel_rows ─────────────────────────────────────────────────────────
1902
+ server.tool('read_panel_rows', 'Read panel row details by row_indices, or page through rows with optional filter/sort/cursor/limit. Use this after a Panel Submit Payload references selected rows, or immediately after render_panel to spot-check 1-3 rows before presenting the panel. Payloads intentionally do not inline complete row data. Large row_indices lists are read in internal batches. Dynamic query_handle and api_jsonl panels support cursor pagination, filtering, sorting by declared sortable fields, and row_indices scanning.', {
1903
+ panel_id: z.string().trim().min(1, 'panel_id is required').describe('The panel ID returned by render_panel or shown in a Panel Submit Payload.'),
1904
+ conversation_id: z.string().trim().min(1).optional().describe('Optional panel owner conversation ID. Omit for panels from the current conversation; pass this when reading a collaborator panel created in another conversation.'),
1905
+ row_indices: z.array(z.number().int().nonnegative()).optional().describe('Specific row indices to read, typically from submitPayload.refs.selectedRowIndices.'),
1906
+ filter: z.object({
1907
+ field: z.string().trim().min(1),
1908
+ value: z.union([z.string(), z.number(), z.boolean()]),
1909
+ }).optional().describe('Optional exact-match filter over a declared filterable field.'),
1910
+ sort: z.object({
1911
+ field: z.string().trim().min(1),
1912
+ direction: z.enum(['asc', 'desc']),
1913
+ }).optional().describe('Optional sort over a declared sortable field.'),
1914
+ cursor: z.string().optional().describe('Pagination cursor returned by a previous read_panel_rows call.'),
1915
+ limit: z.number().int().positive().max(200).optional().describe('Maximum rows to return. Defaults to 50.'),
1916
+ }, async ({ panel_id, conversation_id, row_indices, filter, sort, cursor, limit }) => {
1917
+ try {
1918
+ const panelConversationId = conversation_id?.trim() || conversationId;
1919
+ if (row_indices && row_indices.length > 200) {
1920
+ const rows = [];
1921
+ for (let start = 0; start < row_indices.length; start += 200) {
1922
+ const batch = row_indices.slice(start, start + 200);
1923
+ const { ok, data } = await apiFetch(`/panels/${encodeURIComponent(panel_id)}/rows/read`, {
1924
+ method: 'POST',
1925
+ body: {
1926
+ conversationId: panelConversationId,
1927
+ row_indices: batch,
1928
+ },
1929
+ });
1930
+ if (!ok) {
1931
+ if (data && typeof data === 'object' && 'error' in data) {
1932
+ return toText(`Error: ${String(data.error)}`);
1933
+ }
1934
+ return toText(`Error: ${errText(data, 'read_panel_rows failed')}`);
1935
+ }
1936
+ const record = data && typeof data === 'object' ? data : {};
1937
+ if (Array.isArray(record.rows))
1938
+ rows.push(...record.rows);
1939
+ }
1940
+ return toText(formatPanelRows({ panelId: panel_id, rows, nextCursor: null }));
1941
+ }
1942
+ const { ok, data } = await apiFetch(`/panels/${encodeURIComponent(panel_id)}/rows/read`, {
1943
+ method: 'POST',
1944
+ body: {
1945
+ conversationId: panelConversationId,
1946
+ ...(row_indices ? { row_indices } : {}),
1947
+ ...(filter ? { filter } : {}),
1948
+ ...(sort ? { sort } : {}),
1949
+ ...(cursor ? { cursor } : {}),
1950
+ ...(limit ? { limit } : {}),
1951
+ },
1952
+ });
1953
+ if (!ok) {
1954
+ if (data && typeof data === 'object' && 'error' in data) {
1955
+ return toText(`Error: ${String(data.error)}`);
1956
+ }
1957
+ return toText(`Error: ${errText(data, 'read_panel_rows failed')}`);
1958
+ }
1959
+ return toText(formatPanelRows(data));
1960
+ }
1961
+ catch (err) {
1962
+ return toText(`Error: ${err.message}`);
1963
+ }
1964
+ });
1965
+ // ─── read_panel_events ────────────────────────────────────────────────────────
1966
+ const panelEventTypeSchema = z.enum([
1967
+ 'created',
1968
+ 'patched',
1969
+ 'submitted',
1970
+ 'actioned',
1971
+ 'failed',
1972
+ 'archived',
1973
+ 'selection_committed',
1974
+ 'annotation_saved',
1975
+ 'state_saved',
1976
+ ]);
1977
+ server.tool('read_panel_events', 'Read semantic panel events from the panel audit log. Use this after a user saves annotations, commits selection, submits a form, or triggers an action, then use read_panel_rows for detailed row refs. This returns summaries, wake policy, actor/version/timestamp, and row refs instead of full row or media payloads.', {
1978
+ panel_id: z.string().trim().min(1, 'panel_id is required').describe('The panel ID returned by render_panel/upsert_panel or shown in a Panel Submit Payload.'),
1979
+ conversation_id: z.string().trim().min(1).optional().describe('Optional panel owner conversation ID. Omit for panels from the current conversation; pass this when reading a collaborator panel created in another conversation.'),
1980
+ event_types: z.array(panelEventTypeSchema).optional().describe('Optional event type filter, e.g. ["annotation_saved", "submitted"].'),
1981
+ after_event_id: z.number().int().nonnegative().optional().describe('Only return events with eventId greater than this value. Use the previous next cursor.'),
1982
+ since: z.number().int().nonnegative().optional().describe('Only return events at or after this millisecond timestamp.'),
1983
+ limit: z.number().int().positive().max(100).optional().describe('Maximum events to return. Defaults to 50, max 100.'),
1984
+ }, async ({ panel_id, conversation_id, event_types, after_event_id, since, limit }) => {
1985
+ try {
1986
+ const panelConversationId = conversation_id?.trim() || conversationId;
1987
+ const { ok, data } = await apiFetch(`/panels/${encodeURIComponent(panel_id)}/events/read`, {
1988
+ method: 'POST',
1989
+ body: {
1990
+ conversationId: panelConversationId,
1991
+ ...(event_types ? { event_types } : {}),
1992
+ ...(after_event_id != null ? { after_event_id } : {}),
1993
+ ...(since != null ? { since } : {}),
1994
+ ...(limit ? { limit } : {}),
1995
+ },
1996
+ });
1997
+ if (!ok) {
1998
+ if (data && typeof data === 'object' && 'error' in data) {
1999
+ return toText(`Error: ${String(data.error)}`);
2000
+ }
2001
+ return toText(`Error: ${errText(data, 'read_panel_events failed')}`);
2002
+ }
2003
+ return toText(formatPanelEvents(data));
2004
+ }
2005
+ catch (err) {
2006
+ return toText(`Error: ${err.message}`);
2007
+ }
2008
+ });
2009
+ server.tool('publish_workspace_tool', 'Publish or update a reusable workspace tool mini app from the current agent workspace. Before calling this, write the tool bundle under .agent-tools/<slug>/ and ensure the manifest exists at .agent-tools/<slug>/tool.json. In /tool:plan, do not call this; send a chat wireframe covering actions/status/view layout and wait for confirmation first. New tool creation requires a /tool run or tool maintenance chat; ordinary same-agent follow-up surfaces may only republish an existing same user+agent tool with the same slug. Registered maintainer agents may republish an existing tool only from the same node/workspace as the owner agent; cross-workspace maintainer republish is rejected to avoid implicit execution-owner transfer. For persistent start/restart actions, declare maxRunSeconds when there is a safe upper bound and idleTimeoutSeconds when a silent stdout period should be treated as stale. Use manifest env.condaEnv, env.vars, and env.cwd for shared execution setup instead of repeating conda/env/cd boilerplate in every action command. The platform validates the manifest path, stores the tool, and returns a stable tool URL for the user-facing Tools surface.', {
2010
+ manifest_path: z.string().trim().min(1, 'manifest_path is required').describe('Manifest path inside the current agent workspace. Must resolve to .agent-tools/<slug>/tool.json.'),
2011
+ }, async ({ manifest_path }) => {
2012
+ try {
2013
+ let manifestContent;
2014
+ try {
2015
+ manifestContent = readFileSync(manifest_path, 'utf8');
2016
+ }
2017
+ catch {
2018
+ manifestContent = undefined;
2019
+ }
2020
+ const { ok, data } = await apiFetch('/tools/publish', {
2021
+ method: 'POST',
2022
+ body: {
2023
+ conversationId,
2024
+ runId: currentRunId(),
2025
+ manifestPath: manifest_path,
2026
+ ...(typeof manifestContent === 'string' ? { manifestContent } : {}),
2027
+ },
2028
+ });
2029
+ if (!ok) {
2030
+ return toText(`Error: ${errText(data, 'publish_workspace_tool failed')}`);
2031
+ }
2032
+ const record = data && typeof data === 'object' ? data : {};
2033
+ const toolId = typeof record.toolId === 'string' ? record.toolId : '';
2034
+ const toolUrl = typeof record.toolUrl === 'string' ? record.toolUrl : '';
2035
+ const revision = typeof record.revision === 'number' ? record.revision : null;
2036
+ const diagnostics = Array.isArray(record.diagnostics) ? record.diagnostics : [];
2037
+ const diagnosticLines = diagnostics.flatMap((diagnostic) => {
2038
+ if (!diagnostic || typeof diagnostic !== 'object')
2039
+ return [];
2040
+ const entry = diagnostic;
2041
+ const severity = typeof entry.severity === 'string' ? entry.severity : 'warning';
2042
+ const code = typeof entry.code === 'string' ? entry.code : 'workspace_tool_publish_diagnostic';
2043
+ const message = typeof entry.message === 'string' ? entry.message : '';
2044
+ const actionIds = Array.isArray(entry.actionIds)
2045
+ ? entry.actionIds.filter((actionId) => typeof actionId === 'string' && actionId.length > 0)
2046
+ : [];
2047
+ if (!message)
2048
+ return [];
2049
+ return [`diagnostic_${severity}: [${code}] ${message}${actionIds.length ? ` actions=${actionIds.join(',')}` : ''}`];
2050
+ });
2051
+ const lines = [
2052
+ toolId ? `tool_id: ${toolId}` : 'tool published',
2053
+ toolUrl ? `tool_url: ${toolUrl}` : null,
2054
+ revision != null ? `revision: ${revision}` : null,
2055
+ ...diagnosticLines,
2056
+ ].filter(Boolean);
2057
+ return toText(lines.join('\n'));
2058
+ }
2059
+ catch (err) {
2060
+ return toText(`Error: ${err.message}`);
2061
+ }
2062
+ });
2063
+ // ─── Transport ────────────────────────────────────────────────────────────────
2064
+ const transport = new StdioServerTransport();
2065
+ await server.connect(transport);