@abraca/mcp 1.8.0 → 1.9.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/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
- * Dashboard renders this as a ChatTool indicator.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/mcp",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "MCP server for Abracadabra — AI agent collaboration on CRDT documents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -164,6 +164,10 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
164
164
  interface InlineToken {
165
165
  text: string
166
166
  attrs?: Record<string, unknown>
167
+ /** If set, emit an inline Y.XmlElement with this nodeName instead of text+marks. */
168
+ node?: string
169
+ /** Attributes for the inline node (when node is set). */
170
+ nodeAttrs?: Record<string, string>
167
171
  }
168
172
 
169
173
  function parseInline(text: string): InlineToken[] {
@@ -190,10 +194,9 @@ function parseInline(text: string): InlineToken[] {
190
194
  const kbdProps = parseMdcProps(`{${match[4]}}`)
191
195
  tokens.push({ text: kbdProps['value'] || '', attrs: { kbd: { value: kbdProps['value'] || '' } } })
192
196
  } else if (match[5] !== undefined) {
193
- // Inline wikilink [[docId]] or [[docId|label]] → link to /doc/docId
194
- const docId = match[5]
195
- const displayText = match[6] ?? docId
196
- tokens.push({ text: displayText!, attrs: { link: { href: `/doc/${docId}` } } })
197
+ // Inline wikilink [[docId]] or [[docId|label]] → inline docLink node
198
+ // (the label is derived from the tree at render time; legacy label is dropped)
199
+ tokens.push({ text: '', node: 'docLink', nodeAttrs: { docId: match[5]! } })
197
200
  } else if (match[7] !== undefined) {
198
201
  tokens.push({ text: match[7], attrs: { strike: true } })
199
202
  } else if (match[8] !== undefined) {
@@ -213,7 +216,7 @@ function parseInline(text: string): InlineToken[] {
213
216
  if (lastIndex < stripped.length) {
214
217
  tokens.push({ text: stripped.slice(lastIndex) })
215
218
  }
216
- return tokens.filter(t => t.text.length > 0)
219
+ return tokens.filter(t => t.node || t.text.length > 0)
217
220
  }
218
221
 
219
222
  // ── Block-level parser ───────────────────────────────────────────────────────
@@ -247,7 +250,7 @@ type Block =
247
250
  | { type: 'field'; name: string; fieldType: string; required: boolean; innerBlocks: Block[] }
248
251
  | { type: 'fieldGroup'; fields: Block[] }
249
252
  | { type: 'image'; src: string; alt: string; width?: string; height?: string }
250
- | { type: 'docEmbed'; docId: string }
253
+ | { type: 'docEmbed'; docId: string; seamless?: boolean }
251
254
  | { type: 'svgEmbed'; svg: string; title: string }
252
255
 
253
256
  function parseTableRow(line: string): string[] {
@@ -382,9 +385,11 @@ function parseBlocks(markdown: string): Block[] {
382
385
  continue
383
386
  }
384
387
 
385
- const docEmbedMatch = line.match(/^!\[\[([^\]|]+?)(?:\|[^\]]*?)?\]\]\s*$/)
388
+ const docEmbedMatch = line.match(/^!\[\[([^\]|]+?)(?:\|[^\]]*?)?\]\](\{[^}]*\})?\s*$/)
386
389
  if (docEmbedMatch) {
387
- blocks.push({ type: 'docEmbed', docId: docEmbedMatch[1]! })
390
+ const props = parseMdcProps(docEmbedMatch[2])
391
+ const seamless = 'seamless' in props || props['seamless'] === 'true' || /\{[^}]*\bseamless\b[^}]*\}/.test(docEmbedMatch[2] ?? '')
392
+ blocks.push({ type: 'docEmbed', docId: docEmbedMatch[1]!, seamless: seamless || undefined })
388
393
  i++
389
394
  continue
390
395
  }
@@ -600,17 +605,28 @@ function parseBlocks(markdown: string): Block[] {
600
605
  // ── Y.js content population ──────────────────────────────────────────────────
601
606
 
602
607
  function fillTextInto(el: Y.XmlElement, tokens: InlineToken[]): void {
603
- const filtered = tokens.filter(t => t.text.length > 0)
608
+ const filtered = tokens.filter(t => t.node || t.text.length > 0)
604
609
  if (!filtered.length) return
605
610
 
606
- const xtNodes = filtered.map(() => new Y.XmlText())
607
- el.insert(0, xtNodes)
611
+ const children: (Y.XmlText | Y.XmlElement)[] = filtered.map((tok) => {
612
+ if (tok.node) {
613
+ const xe = new Y.XmlElement(tok.node)
614
+ if (tok.nodeAttrs) {
615
+ for (const [k, v] of Object.entries(tok.nodeAttrs)) xe.setAttribute(k, v)
616
+ }
617
+ return xe
618
+ }
619
+ return new Y.XmlText()
620
+ })
621
+ el.insert(0, children)
608
622
 
609
623
  filtered.forEach((tok, i) => {
624
+ if (tok.node) return
625
+ const xt = children[i] as Y.XmlText
610
626
  if (tok.attrs) {
611
- xtNodes[i]!.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
627
+ xt.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
612
628
  } else {
613
- xtNodes[i]!.insert(0, tok.text)
629
+ xt.insert(0, tok.text)
614
630
  }
615
631
  })
616
632
  }
@@ -837,6 +853,7 @@ function fillBlock(el: Y.XmlElement, block: Block): void {
837
853
  }
838
854
  case 'docEmbed': {
839
855
  el.setAttribute('docId', block.docId)
856
+ if (block.seamless) el.setAttribute('seamless', 'true')
840
857
  break
841
858
  }
842
859
  case 'svgEmbed': {
@@ -38,6 +38,12 @@ function elementTextContent(el: Y.XmlElement | Y.XmlFragment): string {
38
38
  if (child instanceof Y.XmlText) {
39
39
  parts.push(xmlTextToMarkdown(child))
40
40
  } else if (child instanceof Y.XmlElement) {
41
+ // Inline nodes that carry meaning at the inline level.
42
+ if (child.nodeName === 'docLink') {
43
+ const docId = child.getAttribute('docId')
44
+ if (docId) parts.push(`[[${docId}]]`)
45
+ continue
46
+ }
41
47
  parts.push(elementTextContent(child))
42
48
  }
43
49
  }
@@ -97,7 +103,10 @@ function serializeElement(el: Y.XmlElement, indent = ''): string {
97
103
 
98
104
  case 'docEmbed': {
99
105
  const docId = el.getAttribute('docId')
100
- return docId ? `![[${docId}]]` : ''
106
+ if (!docId) return ''
107
+ const seamlessAttr = el.getAttribute('seamless')
108
+ const seamless = seamlessAttr === true || seamlessAttr === 'true'
109
+ return seamless ? `![[${docId}]]{seamless}` : `![[${docId}]]`
101
110
  }
102
111
 
103
112
  case 'svgEmbed': {
@@ -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(payload)
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 clear activeToolCall — let it persist until the next PreToolUse
188
- // replaces it or Stop clears everything. This prevents the status from
189
- // flashing and vanishing between consecutive tool calls.
190
- this.server.setAutoStatus('thinking')
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(_payload: Record<string, any>): void {
200
- // Don't clear let it persist until the next action or Stop
201
- this.server.setAutoStatus('thinking')
208
+ private onSubagentStop(): void {
209
+ // No-opthe 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 (required) — Server URL (e.g. http://localhost:1234)
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' },
@@ -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} (${keypair.publicKeyB64.slice(0, 12)}...)`)
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 (channel?.startsWith('dm:')) {
452
- const parts = channel.split(':')
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
- // Send immediate typing indicator and start periodic re-sends
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
- // Set status to "thinking" so dashboard shows AI is processing
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: data.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, also stop typing interval
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 30s of no updates (generous timeout so status
527
- // persists visibly between consecutive tool calls instead of flickering)
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
- }, 30_000)
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
- * Dashboard renders this as a ChatTool indicator.
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?.awareness.setLocalStateField('activeToolCall', toolCall)
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
 
@@ -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) {