@abraca/mcp 1.8.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abracadabra-mcp.cjs +278 -172
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +278 -172
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/dist/index.d.ts +30 -1
- package/package.json +1 -1
- package/src/hook-bridge.ts +18 -8
- package/src/index.ts +29 -2
- package/src/mentions.ts +42 -0
- package/src/server.ts +126 -14
- package/src/tools/awareness.ts +3 -0
- package/src/tools/channel.ts +18 -8
- package/src/tools/content.ts +0 -5
- package/src/tools/files.ts +8 -0
- package/src/tools/meta.ts +1 -7
- package/src/tools/svg.ts +0 -3
- package/src/tools/tree.ts +1 -20
package/dist/index.d.ts
CHANGED
|
@@ -3,12 +3,23 @@ import { AbracadabraClient, AbracadabraProvider, ServerInfo, SpaceMeta } from "@
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
|
|
5
5
|
//#region packages/mcp/src/server.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Controls when the agent reacts to incoming chat:
|
|
8
|
+
* - `all` — respond to every message in every visible channel (legacy)
|
|
9
|
+
* - `mention` — group chats require `@<alias>`; DMs always respond
|
|
10
|
+
* - `task` — ignore chat entirely; only respond to ai:task awareness events
|
|
11
|
+
* - `mention+task` — group chats require mention OR ai:task; DMs always respond (default)
|
|
12
|
+
*/
|
|
13
|
+
type TriggerMode = 'all' | 'mention' | 'task' | 'mention+task';
|
|
6
14
|
interface MCPServerConfig {
|
|
7
15
|
url: string;
|
|
8
16
|
agentName?: string;
|
|
9
17
|
agentColor?: string;
|
|
10
18
|
inviteCode?: string;
|
|
11
19
|
keyFile?: string;
|
|
20
|
+
triggerMode?: TriggerMode;
|
|
21
|
+
/** Aliases matched in `@<alias>` tokens. Defaults to `[agentName]`. */
|
|
22
|
+
mentionAliases?: string[];
|
|
12
23
|
}
|
|
13
24
|
declare class AbracadabraMCPServer {
|
|
14
25
|
readonly config: MCPServerConfig;
|
|
@@ -28,9 +39,14 @@ declare class AbracadabraMCPServer {
|
|
|
28
39
|
private _typingInterval;
|
|
29
40
|
private _lastChatChannel;
|
|
30
41
|
private _signFn;
|
|
42
|
+
/** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
|
|
43
|
+
private _toolHistory;
|
|
44
|
+
private static readonly TOOL_HISTORY_MAX;
|
|
31
45
|
constructor(config: MCPServerConfig);
|
|
32
46
|
get agentName(): string;
|
|
33
47
|
get agentColor(): string;
|
|
48
|
+
get triggerMode(): TriggerMode;
|
|
49
|
+
get mentionAliases(): string[];
|
|
34
50
|
get serverInfo(): ServerInfo | null;
|
|
35
51
|
get rootDocId(): string | null;
|
|
36
52
|
get spaces(): SpaceMeta[];
|
|
@@ -86,12 +102,25 @@ declare class AbracadabraMCPServer {
|
|
|
86
102
|
* dashboard only shows it in the relevant chat. Defaults to `_lastChatChannel`.
|
|
87
103
|
*/
|
|
88
104
|
setAutoStatus(status: string | null, docId?: string, statusContext?: string | null): void;
|
|
105
|
+
/**
|
|
106
|
+
* Start a new agent turn. Mints a fresh UUID and writes it to awareness so
|
|
107
|
+
* the dashboard can gate the incantation on "there is an active turn",
|
|
108
|
+
* decoupled from the (racier) status field. Called from chat arrival and
|
|
109
|
+
* ai:task dispatch right before `setAutoStatus('thinking')`.
|
|
110
|
+
*/
|
|
111
|
+
private _beginTurn;
|
|
89
112
|
/** Re-send typing indicator every 2s so dashboard keeps showing it (expires at 3s). */
|
|
90
113
|
private _startTypingInterval;
|
|
91
114
|
private _stopTypingInterval;
|
|
92
115
|
/**
|
|
93
116
|
* Broadcast which tool the agent is currently executing.
|
|
94
|
-
*
|
|
117
|
+
*
|
|
118
|
+
* Renders as a ChatTool pill on the dashboard. On non-null calls, the tool
|
|
119
|
+
* is also appended to `toolHistory` (capped at TOOL_HISTORY_MAX) and written
|
|
120
|
+
* to awareness so the dashboard's inline trace can show the turn's recent
|
|
121
|
+
* activity. Tools do NOT clear (`setActiveToolCall(null)`) on completion —
|
|
122
|
+
* the pill stays until the next tool replaces it or `setAutoStatus(null)`
|
|
123
|
+
* flushes the turn. This keeps pills visible long enough to see.
|
|
95
124
|
*/
|
|
96
125
|
setActiveToolCall(toolCall: {
|
|
97
126
|
name: string;
|
package/package.json
CHANGED
package/src/hook-bridge.ts
CHANGED
|
@@ -149,6 +149,9 @@ export class HookBridge {
|
|
|
149
149
|
private routeEvent(payload: Record<string, any>): void {
|
|
150
150
|
const event = payload.hook_event_name
|
|
151
151
|
switch (event) {
|
|
152
|
+
case 'UserPromptSubmit':
|
|
153
|
+
this.onUserPromptSubmit()
|
|
154
|
+
break
|
|
152
155
|
case 'PreToolUse':
|
|
153
156
|
this.onPreToolUse(payload)
|
|
154
157
|
break
|
|
@@ -159,7 +162,7 @@ export class HookBridge {
|
|
|
159
162
|
this.onSubagentStart(payload)
|
|
160
163
|
break
|
|
161
164
|
case 'SubagentStop':
|
|
162
|
-
this.onSubagentStop(
|
|
165
|
+
this.onSubagentStop()
|
|
163
166
|
break
|
|
164
167
|
case 'Stop':
|
|
165
168
|
this.onStop()
|
|
@@ -167,6 +170,12 @@ export class HookBridge {
|
|
|
167
170
|
}
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
/** New user turn — reset any lingering status/tool state from the previous turn. */
|
|
174
|
+
private onUserPromptSubmit(): void {
|
|
175
|
+
this.server.setAutoStatus(null)
|
|
176
|
+
this.server.setActiveToolCall(null)
|
|
177
|
+
}
|
|
178
|
+
|
|
170
179
|
private onPreToolUse(payload: Record<string, any>): void {
|
|
171
180
|
const toolName: string = payload.tool_name ?? ''
|
|
172
181
|
// Skip Abracadabra MCP tools — they set awareness themselves
|
|
@@ -184,10 +193,10 @@ export class HookBridge {
|
|
|
184
193
|
const toolName: string = payload.tool_name ?? ''
|
|
185
194
|
if (toolName.startsWith('mcp__abracadabra__')) return
|
|
186
195
|
|
|
187
|
-
// Don't
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
196
|
+
// Don't touch status — let the short auto-clear timer handle idle
|
|
197
|
+
// detection, or the next PreToolUse / Stop / UserPromptSubmit override it.
|
|
198
|
+
// Don't clear activeToolCall either: the pill stays until the next tool
|
|
199
|
+
// replaces it or the turn ends, preventing flashes between tool calls.
|
|
191
200
|
}
|
|
192
201
|
|
|
193
202
|
private onSubagentStart(payload: Record<string, any>): void {
|
|
@@ -196,9 +205,10 @@ export class HookBridge {
|
|
|
196
205
|
this.server.setAutoStatus('thinking')
|
|
197
206
|
}
|
|
198
207
|
|
|
199
|
-
private onSubagentStop(
|
|
200
|
-
//
|
|
201
|
-
this
|
|
208
|
+
private onSubagentStop(): void {
|
|
209
|
+
// No-op — the parent agent continues working; the next Pre/PostToolUse or
|
|
210
|
+
// Stop will update state. Previously this reset status to 'thinking',
|
|
211
|
+
// which kept the auto-clear timer resetting forever.
|
|
202
212
|
}
|
|
203
213
|
|
|
204
214
|
private onStop(): void {
|
package/src/index.ts
CHANGED
|
@@ -3,15 +3,23 @@
|
|
|
3
3
|
* Abracadabra MCP Server — entry point.
|
|
4
4
|
*
|
|
5
5
|
* Environment variables:
|
|
6
|
-
* ABRA_URL
|
|
6
|
+
* ABRA_URL (required) — Server URL (e.g. http://localhost:1234)
|
|
7
7
|
* ABRA_AGENT_NAME — Display name (default: "AI Assistant")
|
|
8
8
|
* ABRA_AGENT_COLOR — HSL color for presence (default: "hsl(270, 80%, 60%)")
|
|
9
9
|
* ABRA_INVITE_CODE — Invite code for first-run registration (grants role)
|
|
10
10
|
* ABRA_KEY_FILE — Path to Ed25519 key file (default: ~/.abracadabra/agent.key)
|
|
11
|
+
* ABRA_AGENT_TRIGGER_MODE — When to respond in group chats:
|
|
12
|
+
* all → every message (legacy)
|
|
13
|
+
* mention → only when @<alias> is used
|
|
14
|
+
* task → only ai:task awareness events
|
|
15
|
+
* mention+task → mention OR ai:task (default)
|
|
16
|
+
* DMs always trigger regardless of mode.
|
|
17
|
+
* ABRA_AGENT_MENTION_ALIASES — Comma-separated aliases for @mentions
|
|
18
|
+
* (default: [ABRA_AGENT_NAME])
|
|
11
19
|
*/
|
|
12
20
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
13
21
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
14
|
-
import { AbracadabraMCPServer } from './server.ts'
|
|
22
|
+
import { AbracadabraMCPServer, type TriggerMode } from './server.ts'
|
|
15
23
|
import { registerTreeTools } from './tools/tree.ts'
|
|
16
24
|
import { registerContentTools } from './tools/content.ts'
|
|
17
25
|
import { registerMetaTools } from './tools/meta.ts'
|
|
@@ -33,6 +41,21 @@ async function main() {
|
|
|
33
41
|
process.exit(1)
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
// Parse trigger mode (defaults to mention+task)
|
|
45
|
+
const rawMode = (process.env.ABRA_AGENT_TRIGGER_MODE ?? 'mention+task').trim().toLowerCase()
|
|
46
|
+
const validModes: TriggerMode[] = ['all', 'mention', 'task', 'mention+task']
|
|
47
|
+
const triggerMode = (validModes as string[]).includes(rawMode)
|
|
48
|
+
? (rawMode as TriggerMode)
|
|
49
|
+
: 'mention+task'
|
|
50
|
+
if (rawMode && !(validModes as string[]).includes(rawMode)) {
|
|
51
|
+
console.error(`[abracadabra-mcp] Invalid ABRA_AGENT_TRIGGER_MODE="${rawMode}", falling back to "mention+task"`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const aliasEnv = process.env.ABRA_AGENT_MENTION_ALIASES
|
|
55
|
+
const mentionAliases: string[] | undefined = aliasEnv
|
|
56
|
+
? aliasEnv.split(',').map((a: string) => a.trim()).filter((a: string) => a.length > 0)
|
|
57
|
+
: undefined
|
|
58
|
+
|
|
36
59
|
// Create the Abracadabra connection manager
|
|
37
60
|
const server = new AbracadabraMCPServer({
|
|
38
61
|
url,
|
|
@@ -40,8 +63,12 @@ async function main() {
|
|
|
40
63
|
agentColor: process.env.ABRA_AGENT_COLOR,
|
|
41
64
|
inviteCode: process.env.ABRA_INVITE_CODE,
|
|
42
65
|
keyFile: process.env.ABRA_KEY_FILE,
|
|
66
|
+
triggerMode,
|
|
67
|
+
mentionAliases,
|
|
43
68
|
})
|
|
44
69
|
|
|
70
|
+
console.error(`[abracadabra-mcp] Trigger mode: ${triggerMode}; aliases: ${server.mentionAliases.join(', ')}`)
|
|
71
|
+
|
|
45
72
|
// Create MCP server with channel capability
|
|
46
73
|
const mcp = new McpServer(
|
|
47
74
|
{ name: 'abracadabra', version: '1.0.0' },
|
package/src/mentions.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention parsing for chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Recognizes `@alias` tokens (case-insensitive, word-boundary) so the agent
|
|
5
|
+
* can decide whether a group-chat message is directed at it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Escape regex metacharacters in an alias string. */
|
|
9
|
+
function escapeRegex(s: string): string {
|
|
10
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a regex that matches `@<alias>` for any of the given aliases.
|
|
15
|
+
* Requires a non-word char (or start) before `@` and a word boundary after the alias
|
|
16
|
+
* so `@Claude` matches but `email@claudesomething` does not.
|
|
17
|
+
*/
|
|
18
|
+
function buildMentionRegex(aliases: string[]): RegExp | null {
|
|
19
|
+
const cleaned = aliases.map(a => a.trim()).filter(a => a.length > 0)
|
|
20
|
+
if (cleaned.length === 0) return null
|
|
21
|
+
const alt = cleaned.map(escapeRegex).join('|')
|
|
22
|
+
return new RegExp(`(?:^|[^\\w@])@(?:${alt})\\b`, 'i')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Returns true if `text` contains `@alias` for any alias (case-insensitive). */
|
|
26
|
+
export function containsMention(text: string, aliases: string[]): boolean {
|
|
27
|
+
const re = buildMentionRegex(aliases)
|
|
28
|
+
if (!re) return false
|
|
29
|
+
return re.test(text)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Remove `@alias` tokens from the text. Leaves surrounding whitespace tidy so
|
|
34
|
+
* the cleaned prompt reads naturally (e.g. `"@Claude help"` → `"help"`).
|
|
35
|
+
*/
|
|
36
|
+
export function stripMention(text: string, aliases: string[]): string {
|
|
37
|
+
const cleaned = aliases.map(a => a.trim()).filter(a => a.length > 0)
|
|
38
|
+
if (cleaned.length === 0) return text
|
|
39
|
+
const alt = cleaned.map(escapeRegex).join('|')
|
|
40
|
+
const re = new RegExp(`(^|\\s)@(?:${alt})\\b[,:]?\\s*`, 'gi')
|
|
41
|
+
return text.replace(re, (_m, lead) => (lead ? ' ' : '')).replace(/\s{2,}/g, ' ').trim()
|
|
42
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -12,6 +12,16 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
|
12
12
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
13
13
|
import { waitForSync } from './utils.ts'
|
|
14
14
|
import { loadOrCreateKeypair, signChallenge } from './crypto.ts'
|
|
15
|
+
import { containsMention, stripMention } from './mentions.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Controls when the agent reacts to incoming chat:
|
|
19
|
+
* - `all` — respond to every message in every visible channel (legacy)
|
|
20
|
+
* - `mention` — group chats require `@<alias>`; DMs always respond
|
|
21
|
+
* - `task` — ignore chat entirely; only respond to ai:task awareness events
|
|
22
|
+
* - `mention+task` — group chats require mention OR ai:task; DMs always respond (default)
|
|
23
|
+
*/
|
|
24
|
+
export type TriggerMode = 'all' | 'mention' | 'task' | 'mention+task'
|
|
15
25
|
|
|
16
26
|
export interface MCPServerConfig {
|
|
17
27
|
url: string
|
|
@@ -19,6 +29,9 @@ export interface MCPServerConfig {
|
|
|
19
29
|
agentColor?: string
|
|
20
30
|
inviteCode?: string
|
|
21
31
|
keyFile?: string
|
|
32
|
+
triggerMode?: TriggerMode
|
|
33
|
+
/** Aliases matched in `@<alias>` tokens. Defaults to `[agentName]`. */
|
|
34
|
+
mentionAliases?: string[]
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
interface SpaceConnection {
|
|
@@ -52,6 +65,9 @@ export class AbracadabraMCPServer {
|
|
|
52
65
|
private _typingInterval: ReturnType<typeof setInterval> | null = null
|
|
53
66
|
private _lastChatChannel: string | null = null
|
|
54
67
|
private _signFn: ((challenge: string) => Promise<string>) | null = null
|
|
68
|
+
/** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
|
|
69
|
+
private _toolHistory: Array<{ tool: string; target?: string; ts: number; channel: string | null }> = []
|
|
70
|
+
private static readonly TOOL_HISTORY_MAX = 20
|
|
55
71
|
|
|
56
72
|
constructor(config: MCPServerConfig) {
|
|
57
73
|
this.config = config
|
|
@@ -69,6 +85,16 @@ export class AbracadabraMCPServer {
|
|
|
69
85
|
return this.config.agentColor || 'hsl(270, 80%, 60%)'
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
get triggerMode(): TriggerMode {
|
|
89
|
+
return this.config.triggerMode ?? 'mention+task'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get mentionAliases(): string[] {
|
|
93
|
+
const explicit = this.config.mentionAliases?.filter(a => a.trim().length > 0)
|
|
94
|
+
if (explicit && explicit.length > 0) return explicit
|
|
95
|
+
return [this.agentName]
|
|
96
|
+
}
|
|
97
|
+
|
|
72
98
|
get serverInfo(): ServerInfo | null {
|
|
73
99
|
return this._serverInfo
|
|
74
100
|
}
|
|
@@ -121,7 +147,7 @@ export class AbracadabraMCPServer {
|
|
|
121
147
|
throw err
|
|
122
148
|
}
|
|
123
149
|
}
|
|
124
|
-
console.error(`[abracadabra-mcp] Authenticated as ${this.agentName} (
|
|
150
|
+
console.error(`[abracadabra-mcp] Authenticated as ${this.agentName} (pubkey=${keypair.publicKeyB64})`)
|
|
125
151
|
|
|
126
152
|
// Step 3: Discover server info
|
|
127
153
|
this._serverInfo = await this.client.serverInfo()
|
|
@@ -209,6 +235,8 @@ export class AbracadabraMCPServer {
|
|
|
209
235
|
provider.awareness.setLocalStateField('status', null)
|
|
210
236
|
provider.awareness.setLocalStateField('activeToolCall', null)
|
|
211
237
|
provider.awareness.setLocalStateField('statusContext', null)
|
|
238
|
+
provider.awareness.setLocalStateField('turnId', null)
|
|
239
|
+
provider.awareness.setLocalStateField('toolHistory', [])
|
|
212
240
|
|
|
213
241
|
const conn: SpaceConnection = { doc, provider, docId }
|
|
214
242
|
this._spaceConnections.set(docId, conn)
|
|
@@ -359,6 +387,9 @@ export class AbracadabraMCPServer {
|
|
|
359
387
|
private _observeRootAwareness(provider: AbracadabraProvider): void {
|
|
360
388
|
const selfId = provider.awareness.clientID
|
|
361
389
|
provider.awareness.on('change', () => {
|
|
390
|
+
// Strict `mention` mode ignores ai:task awareness; every other mode honors it.
|
|
391
|
+
if (this.triggerMode === 'mention') return
|
|
392
|
+
|
|
362
393
|
const states = provider.awareness.getStates()
|
|
363
394
|
for (const [clientId, state] of states) {
|
|
364
395
|
if (clientId === selfId) continue
|
|
@@ -376,6 +407,7 @@ export class AbracadabraMCPServer {
|
|
|
376
407
|
: 'Unknown'
|
|
377
408
|
|
|
378
409
|
console.error(`[abracadabra-mcp] Handling ai:task id=${id} from ${senderName}: ${text.slice(0, 80)}`)
|
|
410
|
+
this._beginTurn()
|
|
379
411
|
this.setAutoStatus('thinking')
|
|
380
412
|
this._dispatchAiTask({
|
|
381
413
|
id,
|
|
@@ -446,15 +478,45 @@ export class AbracadabraMCPServer {
|
|
|
446
478
|
|
|
447
479
|
const channel = data.channel as string | undefined
|
|
448
480
|
const docId = channel?.startsWith('group:') ? channel.slice(6) : ''
|
|
481
|
+
const isDM = channel?.startsWith('dm:') ?? false
|
|
482
|
+
const isGroup = channel?.startsWith('group:') ?? false
|
|
449
483
|
|
|
450
484
|
// Only process DMs where the agent is a participant
|
|
451
|
-
if (
|
|
452
|
-
const parts = channel
|
|
485
|
+
if (isDM) {
|
|
486
|
+
const parts = channel!.split(':')
|
|
453
487
|
if (parts.length === 3 && parts[1] !== this._userId && parts[2] !== this._userId) {
|
|
488
|
+
console.error(
|
|
489
|
+
`[abracadabra-mcp] Dropping DM: agent _userId=${this._userId} not in channel parts=[${parts[1]}, ${parts[2]}] — channel='${channel}'. ` +
|
|
490
|
+
`The dashboard's awareness likely points at a stale Claude identity.`,
|
|
491
|
+
)
|
|
454
492
|
return
|
|
455
493
|
}
|
|
456
494
|
}
|
|
457
495
|
|
|
496
|
+
// ── Trigger mode gate ─────────────────────────────────────────────
|
|
497
|
+
// DMs always dispatch (implicit summon). Groups obey the trigger mode.
|
|
498
|
+
const mode = this.triggerMode
|
|
499
|
+
const content = typeof data.content === 'string' ? data.content : ''
|
|
500
|
+
let dispatchContent = content
|
|
501
|
+
|
|
502
|
+
if (isGroup) {
|
|
503
|
+
if (mode === 'task') {
|
|
504
|
+
// Chat never triggers in task-only mode
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
if (mode === 'mention' || mode === 'mention+task') {
|
|
508
|
+
const aliases = this.mentionAliases
|
|
509
|
+
if (!containsMention(content, aliases)) {
|
|
510
|
+
console.error(`[abracadabra-mcp] skipped message on ${channel} — no @mention for ${aliases.join('|')}`)
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
// Strip the mention so Claude sees a clean prompt
|
|
514
|
+
dispatchContent = stripMention(content, aliases) || content
|
|
515
|
+
}
|
|
516
|
+
// mode === 'all' falls through unchanged
|
|
517
|
+
}
|
|
518
|
+
// ──────────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
458
520
|
// Auto-mark channel as read so sender sees a read receipt
|
|
459
521
|
if (channel) {
|
|
460
522
|
const rootProvider = this._activeConnection?.provider
|
|
@@ -465,19 +527,22 @@ export class AbracadabraMCPServer {
|
|
|
465
527
|
timestamp: Math.floor(Date.now() / 1000),
|
|
466
528
|
}))
|
|
467
529
|
}
|
|
468
|
-
//
|
|
530
|
+
// Remember the channel so setAutoStatus can scope statusContext to it.
|
|
531
|
+
// NOTE: no typing indicator here — "thinking" status + tool pills are
|
|
532
|
+
// the correct signal while Claude is working; typing is reserved for
|
|
533
|
+
// the actual send_chat_message burst below.
|
|
469
534
|
this._lastChatChannel = channel
|
|
470
|
-
this.sendTypingIndicator(channel)
|
|
471
|
-
this._startTypingInterval(channel)
|
|
472
535
|
}
|
|
473
536
|
|
|
474
|
-
//
|
|
537
|
+
// Mint a fresh turn id so the dashboard ties its incantation + tool
|
|
538
|
+
// trace to THIS turn, and set status to "thinking".
|
|
539
|
+
this._beginTurn()
|
|
475
540
|
this.setAutoStatus('thinking')
|
|
476
541
|
|
|
477
542
|
await this._serverRef.notification({
|
|
478
543
|
method: 'notifications/claude/channel',
|
|
479
544
|
params: {
|
|
480
|
-
content:
|
|
545
|
+
content: dispatchContent,
|
|
481
546
|
instructions: `You MUST use send_chat_message with channel="${channel ?? ''}" for ALL responses — both progress updates and final answers. The user CANNOT see plain text output; they only see messages sent via send_chat_message. When doing multi-step work, send brief status updates via send_chat_message (e.g. "Looking into that..." or "Found it, writing up results...") so the user knows you're working. Never output plain text as a substitute for send_chat_message.`,
|
|
482
547
|
meta: {
|
|
483
548
|
source: 'abracadabra',
|
|
@@ -518,23 +583,47 @@ export class AbracadabraMCPServer {
|
|
|
518
583
|
const context = status ? (statusContext !== undefined ? statusContext : this._lastChatChannel) : null
|
|
519
584
|
provider.awareness.setLocalStateField('statusContext', context ?? null)
|
|
520
585
|
|
|
521
|
-
// When clearing status
|
|
586
|
+
// When clearing status this is the authoritative end-of-turn signal:
|
|
587
|
+
// drop the tool pill, the turn id, and the running tool history so the
|
|
588
|
+
// dashboard's incantation + activity trace all collapse in lockstep.
|
|
522
589
|
if (!status) {
|
|
523
590
|
this._stopTypingInterval()
|
|
591
|
+
provider.awareness.setLocalStateField('activeToolCall', null)
|
|
592
|
+
provider.awareness.setLocalStateField('turnId', null)
|
|
593
|
+
this._toolHistory = []
|
|
594
|
+
provider.awareness.setLocalStateField('toolHistory', [])
|
|
524
595
|
}
|
|
525
596
|
|
|
526
|
-
// Auto-clear status after
|
|
527
|
-
//
|
|
597
|
+
// Auto-clear status after 10s of no updates. Short enough that a real
|
|
598
|
+
// idle is noticed quickly; long enough that normal inter-tool gaps
|
|
599
|
+
// (PostToolUse → next PreToolUse) don't flicker.
|
|
528
600
|
if (status) {
|
|
529
601
|
this._statusClearTimer = setTimeout(() => {
|
|
530
602
|
provider.awareness.setLocalStateField('status', null)
|
|
531
603
|
provider.awareness.setLocalStateField('activeToolCall', null)
|
|
532
604
|
provider.awareness.setLocalStateField('statusContext', null)
|
|
605
|
+
provider.awareness.setLocalStateField('turnId', null)
|
|
606
|
+
this._toolHistory = []
|
|
607
|
+
provider.awareness.setLocalStateField('toolHistory', [])
|
|
533
608
|
this._stopTypingInterval()
|
|
534
|
-
},
|
|
609
|
+
}, 10_000)
|
|
535
610
|
}
|
|
536
611
|
}
|
|
537
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Start a new agent turn. Mints a fresh UUID and writes it to awareness so
|
|
615
|
+
* the dashboard can gate the incantation on "there is an active turn",
|
|
616
|
+
* decoupled from the (racier) status field. Called from chat arrival and
|
|
617
|
+
* ai:task dispatch right before `setAutoStatus('thinking')`.
|
|
618
|
+
*/
|
|
619
|
+
private _beginTurn(): void {
|
|
620
|
+
const provider = this._activeConnection?.provider
|
|
621
|
+
if (!provider) return
|
|
622
|
+
this._toolHistory = []
|
|
623
|
+
provider.awareness.setLocalStateField('toolHistory', [])
|
|
624
|
+
provider.awareness.setLocalStateField('turnId', crypto.randomUUID())
|
|
625
|
+
}
|
|
626
|
+
|
|
538
627
|
/** Re-send typing indicator every 2s so dashboard keeps showing it (expires at 3s). */
|
|
539
628
|
private _startTypingInterval(channel: string): void {
|
|
540
629
|
this._stopTypingInterval()
|
|
@@ -553,10 +642,30 @@ export class AbracadabraMCPServer {
|
|
|
553
642
|
|
|
554
643
|
/**
|
|
555
644
|
* Broadcast which tool the agent is currently executing.
|
|
556
|
-
*
|
|
645
|
+
*
|
|
646
|
+
* Renders as a ChatTool pill on the dashboard. On non-null calls, the tool
|
|
647
|
+
* is also appended to `toolHistory` (capped at TOOL_HISTORY_MAX) and written
|
|
648
|
+
* to awareness so the dashboard's inline trace can show the turn's recent
|
|
649
|
+
* activity. Tools do NOT clear (`setActiveToolCall(null)`) on completion —
|
|
650
|
+
* the pill stays until the next tool replaces it or `setAutoStatus(null)`
|
|
651
|
+
* flushes the turn. This keeps pills visible long enough to see.
|
|
557
652
|
*/
|
|
558
653
|
setActiveToolCall(toolCall: { name: string; target?: string } | null): void {
|
|
559
|
-
this._activeConnection?.provider
|
|
654
|
+
const provider = this._activeConnection?.provider
|
|
655
|
+
if (!provider) return
|
|
656
|
+
provider.awareness.setLocalStateField('activeToolCall', toolCall)
|
|
657
|
+
if (toolCall) {
|
|
658
|
+
this._toolHistory.push({
|
|
659
|
+
tool: toolCall.name,
|
|
660
|
+
target: toolCall.target,
|
|
661
|
+
ts: Date.now(),
|
|
662
|
+
channel: this._lastChatChannel,
|
|
663
|
+
})
|
|
664
|
+
if (this._toolHistory.length > AbracadabraMCPServer.TOOL_HISTORY_MAX) {
|
|
665
|
+
this._toolHistory.splice(0, this._toolHistory.length - AbracadabraMCPServer.TOOL_HISTORY_MAX)
|
|
666
|
+
}
|
|
667
|
+
provider.awareness.setLocalStateField('toolHistory', [...this._toolHistory])
|
|
668
|
+
}
|
|
560
669
|
}
|
|
561
670
|
|
|
562
671
|
/**
|
|
@@ -594,8 +703,11 @@ export class AbracadabraMCPServer {
|
|
|
594
703
|
conn.provider.awareness.setLocalStateField('status', null)
|
|
595
704
|
conn.provider.awareness.setLocalStateField('activeToolCall', null)
|
|
596
705
|
conn.provider.awareness.setLocalStateField('statusContext', null)
|
|
706
|
+
conn.provider.awareness.setLocalStateField('turnId', null)
|
|
707
|
+
conn.provider.awareness.setLocalStateField('toolHistory', [])
|
|
597
708
|
conn.provider.destroy()
|
|
598
709
|
}
|
|
710
|
+
this._toolHistory = []
|
|
599
711
|
this._spaceConnections.clear()
|
|
600
712
|
this._activeConnection = null
|
|
601
713
|
|
package/src/tools/awareness.ts
CHANGED
|
@@ -38,6 +38,7 @@ export function registerAwarenessTools(mcp: McpServer, server: AbracadabraMCPSer
|
|
|
38
38
|
fields: z.record(z.string(), z.unknown()).describe('Key-value pairs to set on the child document\'s awareness state. Use namespaced keys like "kanban:hovering", "table:editing", "slides:viewing", "outline:editing", "calendar:focused", "gallery:focused", "timeline:focused", "graph:focused", "map:focused", "doc:scroll". Set a key to null to clear it.'),
|
|
39
39
|
},
|
|
40
40
|
async ({ docId, fields }) => {
|
|
41
|
+
server.setActiveToolCall({ name: 'set_doc_awareness', target: docId })
|
|
41
42
|
try {
|
|
42
43
|
const provider = await server.getChildProvider(docId)
|
|
43
44
|
for (const [key, value] of Object.entries(fields)) {
|
|
@@ -58,6 +59,7 @@ export function registerAwarenessTools(mcp: McpServer, server: AbracadabraMCPSer
|
|
|
58
59
|
'Check the "AI Inbox" document for pending instructions from humans. Returns the inbox content and any pending task sub-documents. Create the inbox as a doc called "AI Inbox" under the hub doc if it does not exist yet. Note: channel-based watching via watch_chat is preferred for real-time use.',
|
|
59
60
|
{},
|
|
60
61
|
async () => {
|
|
62
|
+
server.setActiveToolCall({ name: 'poll_inbox' })
|
|
61
63
|
try {
|
|
62
64
|
const treeMap = server.getTreeMap()
|
|
63
65
|
const rootDocId = server.rootDocId
|
|
@@ -124,6 +126,7 @@ export function registerAwarenessTools(mcp: McpServer, server: AbracadabraMCPSer
|
|
|
124
126
|
docId: z.string().optional().describe('If provided, list users connected to this specific document. Otherwise lists users from root awareness.'),
|
|
125
127
|
},
|
|
126
128
|
async ({ docId }) => {
|
|
129
|
+
server.setActiveToolCall({ name: 'list_connected_users', target: docId })
|
|
127
130
|
try {
|
|
128
131
|
let awareness
|
|
129
132
|
if (docId) {
|
package/src/tools/channel.ts
CHANGED
|
@@ -23,7 +23,6 @@ export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServe
|
|
|
23
23
|
const treeMap = server.getTreeMap()
|
|
24
24
|
const rootDoc = server.rootDocument
|
|
25
25
|
if (!treeMap || !rootDoc) {
|
|
26
|
-
server.setActiveToolCall(null)
|
|
27
26
|
return { content: [{ type: 'text' as const, text: 'Not connected' }], isError: true }
|
|
28
27
|
}
|
|
29
28
|
|
|
@@ -51,13 +50,11 @@ export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServe
|
|
|
51
50
|
server.clearAiTask(task_id)
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
server.setActiveToolCall(null)
|
|
55
53
|
|
|
56
54
|
return {
|
|
57
55
|
content: [{ type: 'text' as const, text: JSON.stringify({ replyDocId: replyId, parentId: doc_id }) }],
|
|
58
56
|
}
|
|
59
57
|
} catch (error: any) {
|
|
60
|
-
server.setActiveToolCall(null)
|
|
61
58
|
return {
|
|
62
59
|
content: [{ type: 'text' as const, text: `Error: ${error.message}` }],
|
|
63
60
|
isError: true,
|
|
@@ -80,17 +77,30 @@ export function registerChannelTools(mcp: McpServer, server: AbracadabraMCPServe
|
|
|
80
77
|
return { content: [{ type: 'text' as const, text: 'Not connected' }], isError: true }
|
|
81
78
|
}
|
|
82
79
|
|
|
80
|
+
// Normalize literal escape sequences. Some LLM outputs emit the
|
|
81
|
+
// 2-char `\n` / `\t` / `\r` instead of the real control chars, which
|
|
82
|
+
// then render literally in the chat (markdown renderer can't see a
|
|
83
|
+
// newline where there is just "\n"). Convert them back to real chars.
|
|
84
|
+
const normalized = text
|
|
85
|
+
.replace(/\\r\\n/g, '\n')
|
|
86
|
+
.replace(/\\n/g, '\n')
|
|
87
|
+
.replace(/\\t/g, '\t')
|
|
88
|
+
.replace(/\\r/g, '\n')
|
|
89
|
+
|
|
90
|
+
// Order matters: clear status + tool pill FIRST so the dashboard's
|
|
91
|
+
// typing-indicator filter (which hides typing while an activeToolCall
|
|
92
|
+
// exists) doesn't swallow the burst. Then emit the typing frame, then
|
|
93
|
+
// the actual chat:send. The clear also flushes toolHistory + turnId.
|
|
94
|
+
server.setAutoStatus(null)
|
|
95
|
+
server.sendTypingIndicator(channel)
|
|
96
|
+
|
|
83
97
|
rootProvider.sendStateless(JSON.stringify({
|
|
84
98
|
type: 'chat:send',
|
|
85
99
|
channel,
|
|
86
|
-
content:
|
|
100
|
+
content: normalized,
|
|
87
101
|
sender_name: server.agentName,
|
|
88
102
|
}))
|
|
89
103
|
|
|
90
|
-
// Clear thinking/typing status after sending the reply
|
|
91
|
-
server.setAutoStatus(null)
|
|
92
|
-
server.setActiveToolCall(null)
|
|
93
|
-
|
|
94
104
|
return { content: [{ type: 'text' as const, text: `Sent to ${channel}` }] }
|
|
95
105
|
} catch (error: any) {
|
|
96
106
|
return {
|
package/src/tools/content.ts
CHANGED
|
@@ -51,8 +51,6 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
|
|
|
51
51
|
children.sort((a: any, b: any) => ((treeMap.get(a.id)?.order ?? 0) - (treeMap.get(b.id)?.order ?? 0)))
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
server.setActiveToolCall(null)
|
|
55
|
-
|
|
56
54
|
const result: Record<string, unknown> = { label, type, meta, markdown, children }
|
|
57
55
|
return {
|
|
58
56
|
content: [{
|
|
@@ -61,7 +59,6 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
|
|
|
61
59
|
}],
|
|
62
60
|
}
|
|
63
61
|
} catch (error: any) {
|
|
64
|
-
server.setActiveToolCall(null)
|
|
65
62
|
return {
|
|
66
63
|
content: [{ type: 'text', text: `Error reading document: ${error.message}` }],
|
|
67
64
|
isError: true,
|
|
@@ -128,13 +125,11 @@ export function registerContentTools(mcp: McpServer, server: AbracadabraMCPServe
|
|
|
128
125
|
server.setFocusedDoc(docId)
|
|
129
126
|
server.setDocCursor(docId, fragment.length)
|
|
130
127
|
|
|
131
|
-
server.setActiveToolCall(null)
|
|
132
128
|
|
|
133
129
|
return {
|
|
134
130
|
content: [{ type: 'text', text: `Document ${docId} updated (${writeMode} mode)` }],
|
|
135
131
|
}
|
|
136
132
|
} catch (error: any) {
|
|
137
|
-
server.setActiveToolCall(null)
|
|
138
133
|
return {
|
|
139
134
|
content: [{ type: 'text', text: `Error writing document: ${error.message}` }],
|
|
140
135
|
isError: true,
|
package/src/tools/files.ts
CHANGED
|
@@ -15,6 +15,8 @@ export function registerFileTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
15
15
|
docId: z.string().describe('Document ID.'),
|
|
16
16
|
},
|
|
17
17
|
async ({ docId }) => {
|
|
18
|
+
server.setAutoStatus('reading', docId)
|
|
19
|
+
server.setActiveToolCall({ name: 'list_uploads', target: docId })
|
|
18
20
|
try {
|
|
19
21
|
const uploads = await server.client.listUploads(docId)
|
|
20
22
|
return {
|
|
@@ -41,6 +43,8 @@ export function registerFileTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
41
43
|
filename: z.string().optional().describe('Override filename (defaults to basename of filePath).'),
|
|
42
44
|
},
|
|
43
45
|
async ({ docId, filePath, filename }) => {
|
|
46
|
+
server.setAutoStatus('uploading', docId)
|
|
47
|
+
server.setActiveToolCall({ name: 'upload_file', target: path.basename(filePath) })
|
|
44
48
|
try {
|
|
45
49
|
const resolvedPath = path.resolve(filePath)
|
|
46
50
|
const data = fs.readFileSync(resolvedPath)
|
|
@@ -71,6 +75,8 @@ export function registerFileTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
71
75
|
saveTo: z.string().describe('Absolute local file path to save the download.'),
|
|
72
76
|
},
|
|
73
77
|
async ({ docId, uploadId, saveTo }) => {
|
|
78
|
+
server.setAutoStatus('reading', docId)
|
|
79
|
+
server.setActiveToolCall({ name: 'download_file', target: path.basename(saveTo) })
|
|
74
80
|
try {
|
|
75
81
|
const blob = await server.client.getUpload(docId, uploadId)
|
|
76
82
|
const buffer = Buffer.from(await blob.arrayBuffer())
|
|
@@ -96,6 +102,8 @@ export function registerFileTools(mcp: McpServer, server: AbracadabraMCPServer)
|
|
|
96
102
|
uploadId: z.string().describe('Upload ID to delete.'),
|
|
97
103
|
},
|
|
98
104
|
async ({ docId, uploadId }) => {
|
|
105
|
+
server.setAutoStatus('writing', docId)
|
|
106
|
+
server.setActiveToolCall({ name: 'delete_file', target: uploadId })
|
|
99
107
|
try {
|
|
100
108
|
await server.client.deleteUpload(docId, uploadId)
|
|
101
109
|
return { content: [{ type: 'text', text: `Deleted upload ${uploadId}` }] }
|