@abraca/mcp 2.6.0 → 2.8.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.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import * as Y from "yjs";
2
1
  import { AbracadabraClient, AbracadabraProvider, DocumentMeta, ServerInfo } from "@abraca/dabra";
3
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import * as Y from "yjs";
4
4
 
5
5
  //#region packages/mcp/src/server.d.ts
6
6
  /**
@@ -10,7 +10,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  * - `task` — ignore chat entirely; only respond to ai:task awareness events
11
11
  * - `mention+task` — group chats require mention OR ai:task; DMs always respond (default)
12
12
  */
13
- type TriggerMode = 'all' | 'mention' | 'task' | 'mention+task';
13
+ type TriggerMode = "all" | "mention" | "task" | "mention+task";
14
14
  interface MCPServerConfig {
15
15
  url: string;
16
16
  agentName?: string;
@@ -38,6 +38,7 @@ declare class AbracadabraMCPServer {
38
38
  private _statusClearTimer;
39
39
  private _typingInterval;
40
40
  private _lastChatChannel;
41
+ private _activeTurnChannel;
41
42
  private _signFn;
42
43
  /** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
43
44
  private _toolHistory;
@@ -79,6 +80,16 @@ declare class AbracadabraMCPServer {
79
80
  switchSpace(docId: string): Promise<void>;
80
81
  /** Get the root doc-tree Y.Map of the active space. */
81
82
  getTreeMap(): Y.Map<any> | null;
83
+ /**
84
+ * Resolve a doc's `{label, type}` from the active space's tree map. Used to
85
+ * enrich chat-dispatch notifications so Claude knows what kind of doc the
86
+ * chat is attached to (e.g. checklist vs. kanban) without burning tool
87
+ * calls to discover it. Returns null when the doc isn't in this tree.
88
+ */
89
+ getDocSummary(docId: string): {
90
+ label?: string;
91
+ type?: string;
92
+ } | null;
82
93
  /** Get the root doc-trash Y.Map of the active space. */
83
94
  getTrashMap(): Y.Map<any> | null;
84
95
  /** Get plugin names enabled in the active space via space-plugins Y.Map. */
@@ -128,7 +139,10 @@ declare class AbracadabraMCPServer {
128
139
  * Falls back to the preview, then a short notice.
129
140
  */
130
141
  private _resolveInboxContent;
142
+ /** Check-and-mark: true if already dispatched, else records it and returns false. */
131
143
  private _rememberDispatched;
144
+ /** Check-only: true if this id was already dispatched (does NOT record it). */
145
+ private _wasDispatched;
132
146
  private _trimSet;
133
147
  /** Attach awareness observer to detect `ai:task` fields from human users. */
134
148
  private _observeRootAwareness;
@@ -152,8 +166,20 @@ declare class AbracadabraMCPServer {
152
166
  * the dashboard can gate the incantation on "there is an active turn",
153
167
  * decoupled from the (racier) status field. Called from chat arrival and
154
168
  * ai:task dispatch right before `setAutoStatus('thinking')`.
169
+ *
170
+ * Also starts a heartbeat typing indicator so the dashboard renders typing
171
+ * dots whenever no tool pill is active during the turn — this replaces the
172
+ * "dead air" gap users see between thinking and the final reply.
155
173
  */
156
174
  private _beginTurn;
175
+ /**
176
+ * Enter the "writing" phase: the agent has finished reasoning and is about
177
+ * to send a chat message. The dashboard maps `status === 'writing'` to
178
+ * typing dots (not the incantation phrase). Keeps the turn alive (turnId
179
+ * stays set) and emits a typing frame immediately so the dots appear at
180
+ * once; the heartbeat keeps them alive until `setAutoStatus(null)`.
181
+ */
182
+ setWritingStatus(channel: string): void;
157
183
  /** Re-send typing indicator every 2s so dashboard keeps showing it (expires at 3s). */
158
184
  private _startTypingInterval;
159
185
  private _stopTypingInterval;
@@ -170,6 +196,7 @@ declare class AbracadabraMCPServer {
170
196
  setActiveToolCall(toolCall: {
171
197
  name: string;
172
198
  target?: string;
199
+ detail?: unknown;
173
200
  } | null): void;
174
201
  /**
175
202
  * Send a typing indicator to a chat channel. Pass the channel doc id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/mcp",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "MCP server for Abracadabra — AI agent collaboration on CRDT documents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "y-protocols": "^1.0.6",
37
37
  "yjs": "^13.6.8",
38
38
  "zod": "^4.3.6",
39
- "@abraca/convert": "2.6.0"
39
+ "@abraca/convert": "2.8.0"
40
40
  },
41
41
  "scripts": {
42
42
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -6,213 +6,321 @@
6
6
  * POST JSON to http://127.0.0.1:{port}/hook. The bridge maps these to awareness
7
7
  * fields (status, activeToolCall) so the cou-sh dashboard shows real-time activity.
8
8
  */
9
- import * as http from 'node:http'
10
- import * as fs from 'node:fs'
11
- import * as os from 'node:os'
12
- import * as path from 'node:path'
13
- import type { AbracadabraMCPServer } from './server.ts'
14
-
15
- /** Map Claude Code tool names to awareness-friendly names + extract a target string. */
16
- function mapToolCall(toolName: string, toolInput: Record<string, any>): { name: string; target?: string } | null {
17
- switch (toolName) {
18
- case 'Bash':
19
- return { name: 'bash', target: truncate(toolInput.command ?? toolInput.description, 60) }
20
- case 'Read':
21
- return { name: 'read_file', target: basename(toolInput.file_path) }
22
- case 'Edit':
23
- return { name: 'edit_file', target: basename(toolInput.file_path) }
24
- case 'Write':
25
- return { name: 'write_file', target: basename(toolInput.file_path) }
26
- case 'Grep':
27
- return { name: 'grep', target: truncate(toolInput.pattern, 40) }
28
- case 'Glob':
29
- return { name: 'glob', target: truncate(toolInput.pattern, 40) }
30
- case 'Agent':
31
- return { name: 'subagent', target: toolInput.description || toolInput.subagent_type || 'agent' }
32
- case 'WebFetch':
33
- return { name: 'web_fetch', target: hostname(toolInput.url) }
34
- case 'WebSearch':
35
- return { name: 'web_search', target: truncate(toolInput.query, 40) }
36
- default:
37
- // Unknown tool — use the name as-is, lowercased with underscores
38
- return { name: toolName.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase() }
39
- }
9
+
10
+ import * as fs from "node:fs";
11
+ import * as http from "node:http";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import type { AbracadabraMCPServer } from "./server.ts";
15
+
16
+ /**
17
+ * Expandable detail shown when a tool card is clicked in the dashboard
18
+ * (mirrors the Claude CLI's inline tool views). Strings are capped so the
19
+ * awareness payload stays bounded.
20
+ */
21
+ export type ToolDetail =
22
+ | { kind: "diff"; file?: string; old?: string; new?: string }
23
+ | { kind: "code"; file?: string; text?: string }
24
+ | { kind: "command"; text?: string }
25
+ | { kind: "text"; text?: string };
26
+
27
+ /** Per-field cap for detail strings (chars). Keeps awareness lean. */
28
+ const DETAIL_MAX = 4000;
29
+
30
+ function cap(s: string | undefined): string | undefined {
31
+ if (typeof s !== "string") return undefined;
32
+ return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… (truncated)` : s;
33
+ }
34
+
35
+ /** Map Claude Code tool names to awareness-friendly names + target + detail. */
36
+ function mapToolCall(
37
+ toolName: string,
38
+ toolInput: Record<string, any>,
39
+ ): { name: string; target?: string; detail?: ToolDetail } | null {
40
+ switch (toolName) {
41
+ case "Bash":
42
+ return {
43
+ name: "bash",
44
+ target: truncate(toolInput.command ?? toolInput.description, 60),
45
+ detail: { kind: "command", text: cap(toolInput.command) },
46
+ };
47
+ case "Read":
48
+ return {
49
+ name: "read_file",
50
+ target: basename(toolInput.file_path),
51
+ detail: { kind: "text", text: toolInput.file_path },
52
+ };
53
+ case "Edit": {
54
+ // Single-edit ({old_string,new_string}) or multi-edit ({edits:[…]}).
55
+ let oldText: string | undefined = toolInput.old_string;
56
+ let newText: string | undefined = toolInput.new_string;
57
+ if (!oldText && Array.isArray(toolInput.edits)) {
58
+ oldText = toolInput.edits
59
+ .map((e: any) => e?.old_string ?? "")
60
+ .join("\n");
61
+ newText = toolInput.edits
62
+ .map((e: any) => e?.new_string ?? "")
63
+ .join("\n");
64
+ }
65
+ return {
66
+ name: "edit_file",
67
+ target: basename(toolInput.file_path),
68
+ detail: {
69
+ kind: "diff",
70
+ file: toolInput.file_path,
71
+ old: cap(oldText),
72
+ new: cap(newText),
73
+ },
74
+ };
75
+ }
76
+ case "Write":
77
+ return {
78
+ name: "write_file",
79
+ target: basename(toolInput.file_path),
80
+ detail: {
81
+ kind: "code",
82
+ file: toolInput.file_path,
83
+ text: cap(toolInput.content),
84
+ },
85
+ };
86
+ case "Grep":
87
+ return {
88
+ name: "grep",
89
+ target: truncate(toolInput.pattern, 40),
90
+ detail: { kind: "text", text: toolInput.pattern },
91
+ };
92
+ case "Glob":
93
+ return {
94
+ name: "glob",
95
+ target: truncate(toolInput.pattern, 40),
96
+ detail: { kind: "text", text: toolInput.pattern },
97
+ };
98
+ case "Agent":
99
+ return {
100
+ name: "subagent",
101
+ target: toolInput.description || toolInput.subagent_type || "agent",
102
+ detail: { kind: "text", text: cap(toolInput.prompt) },
103
+ };
104
+ case "WebFetch":
105
+ return {
106
+ name: "web_fetch",
107
+ target: hostname(toolInput.url),
108
+ detail: { kind: "text", text: toolInput.url },
109
+ };
110
+ case "WebSearch":
111
+ return {
112
+ name: "web_search",
113
+ target: truncate(toolInput.query, 40),
114
+ detail: { kind: "text", text: toolInput.query },
115
+ };
116
+ default:
117
+ // Unknown tool — use the name as-is, lowercased with underscores
118
+ return {
119
+ name: toolName.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(),
120
+ };
121
+ }
40
122
  }
41
123
 
42
124
  function truncate(str: string | undefined, max: number): string | undefined {
43
- if (!str) return undefined
44
- return str.length > max ? str.slice(0, max) + '...' : str
125
+ if (!str) return undefined;
126
+ return str.length > max ? str.slice(0, max) + "..." : str;
45
127
  }
46
128
 
47
129
  function basename(filePath: string | undefined): string | undefined {
48
- if (!filePath) return undefined
49
- return path.basename(filePath)
130
+ if (!filePath) return undefined;
131
+ return path.basename(filePath);
50
132
  }
51
133
 
52
134
  function hostname(url: string | undefined): string | undefined {
53
- if (!url) return undefined
54
- try {
55
- return new URL(url).hostname
56
- } catch {
57
- return url.slice(0, 30)
58
- }
135
+ if (!url) return undefined;
136
+ try {
137
+ return new URL(url).hostname;
138
+ } catch {
139
+ return url.slice(0, 30);
140
+ }
59
141
  }
60
142
 
61
143
  export class HookBridge {
62
- private httpServer: http.Server | null = null
63
- private _port: number | null = null
64
- private portFilePath: string
65
-
66
- constructor(private server: AbracadabraMCPServer) {
67
- this.portFilePath = process.env.ABRA_HOOK_PORT_FILE
68
- || path.join(os.tmpdir(), 'abracadabra-mcp-hook.port')
69
- }
70
-
71
- get port(): number | null {
72
- return this._port
73
- }
74
-
75
- /** Start the HTTP server on a random port and write the port file. */
76
- async start(): Promise<number> {
77
- return new Promise((resolve, reject) => {
78
- const srv = http.createServer((req, res) => this.handleRequest(req, res))
79
-
80
- srv.on('error', reject)
81
-
82
- srv.listen(0, '127.0.0.1', () => {
83
- const addr = srv.address()
84
- if (!addr || typeof addr === 'string') {
85
- reject(new Error('Failed to get server address'))
86
- return
87
- }
88
-
89
- this._port = addr.port
90
- this.httpServer = srv
91
-
92
- // Write port file for hook discovery
93
- try {
94
- fs.writeFileSync(this.portFilePath, String(this._port), 'utf-8')
95
- } catch (err: any) {
96
- console.error(`[hook-bridge] Warning: could not write port file: ${err.message}`)
97
- }
98
-
99
- console.error(`[hook-bridge] Listening on 127.0.0.1:${this._port}`)
100
- console.error(`[hook-bridge] Port file: ${this.portFilePath}`)
101
- resolve(this._port)
102
- })
103
- })
104
- }
105
-
106
- /** Shut down the HTTP server and remove the port file. */
107
- async destroy(): Promise<void> {
108
- if (this.httpServer) {
109
- await new Promise<void>((resolve) => {
110
- this.httpServer!.close(() => resolve())
111
- })
112
- this.httpServer = null
113
- }
114
-
115
- // Remove port file
116
- try {
117
- fs.unlinkSync(this.portFilePath)
118
- } catch {
119
- // Already gone or never written
120
- }
121
-
122
- this._port = null
123
- console.error('[hook-bridge] Shut down')
124
- }
125
-
126
- private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
127
- // Only accept POST /hook
128
- if (req.method !== 'POST' || req.url !== '/hook') {
129
- res.writeHead(404)
130
- res.end()
131
- return
132
- }
133
-
134
- let body = ''
135
- req.on('data', (chunk: Buffer) => { body += chunk.toString() })
136
- req.on('end', () => {
137
- res.writeHead(200, { 'Content-Type': 'application/json' })
138
- res.end('{}')
139
-
140
- try {
141
- const payload = JSON.parse(body)
142
- this.routeEvent(payload)
143
- } catch {
144
- // Invalid JSON — ignore silently (fire-and-forget)
145
- }
146
- })
147
- }
148
-
149
- private routeEvent(payload: Record<string, any>): void {
150
- const event = payload.hook_event_name
151
- switch (event) {
152
- case 'UserPromptSubmit':
153
- this.onUserPromptSubmit()
154
- break
155
- case 'PreToolUse':
156
- this.onPreToolUse(payload)
157
- break
158
- case 'PostToolUse':
159
- this.onPostToolUse(payload)
160
- break
161
- case 'SubagentStart':
162
- this.onSubagentStart(payload)
163
- break
164
- case 'SubagentStop':
165
- this.onSubagentStop()
166
- break
167
- case 'Stop':
168
- this.onStop()
169
- break
170
- }
171
- }
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
-
179
- private onPreToolUse(payload: Record<string, any>): void {
180
- const toolName: string = payload.tool_name ?? ''
181
- // Skip Abracadabra MCP tools — they set awareness themselves
182
- if (toolName.startsWith('mcp__abracadabra__')) return
183
-
184
- const toolInput = payload.tool_input ?? {}
185
- const mapped = mapToolCall(toolName, toolInput)
186
- if (mapped) {
187
- this.server.setActiveToolCall(mapped)
188
- this.server.setAutoStatus('working')
189
- }
190
- }
191
-
192
- private onPostToolUse(payload: Record<string, any>): void {
193
- const toolName: string = payload.tool_name ?? ''
194
- if (toolName.startsWith('mcp__abracadabra__')) return
195
-
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.
200
- }
201
-
202
- private onSubagentStart(payload: Record<string, any>): void {
203
- const agentType: string = payload.agent_type ?? 'agent'
204
- this.server.setActiveToolCall({ name: 'subagent', target: agentType })
205
- this.server.setAutoStatus('thinking')
206
- }
207
-
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.
212
- }
213
-
214
- private onStop(): void {
215
- this.server.setAutoStatus(null)
216
- this.server.setActiveToolCall(null)
217
- }
144
+ private httpServer: http.Server | null = null;
145
+ private _port: number | null = null;
146
+ private portFilePath: string;
147
+
148
+ constructor(private server: AbracadabraMCPServer) {
149
+ // Stable, predictable path (NOT tmpdir) so the Claude Code hook command
150
+ // can read it without depending on a possibly-different $TMPDIR at hook
151
+ // execution time. Override with ABRA_HOOK_PORT_FILE if needed.
152
+ this.portFilePath =
153
+ process.env.ABRA_HOOK_PORT_FILE ||
154
+ path.join(os.homedir(), ".abracadabra-mcp-hook.port");
155
+ }
156
+
157
+ get port(): number | null {
158
+ return this._port;
159
+ }
160
+
161
+ /** Absolute path of the file the current listening port is written to. */
162
+ get portFile(): string {
163
+ return this.portFilePath;
164
+ }
165
+
166
+ /** Start the HTTP server on a random port and write the port file. */
167
+ async start(): Promise<number> {
168
+ return new Promise((resolve, reject) => {
169
+ const srv = http.createServer((req, res) => this.handleRequest(req, res));
170
+
171
+ srv.on("error", reject);
172
+
173
+ srv.listen(0, "127.0.0.1", () => {
174
+ const addr = srv.address();
175
+ if (!addr || typeof addr === "string") {
176
+ reject(new Error("Failed to get server address"));
177
+ return;
178
+ }
179
+
180
+ this._port = addr.port;
181
+ this.httpServer = srv;
182
+
183
+ // Write port file for hook discovery
184
+ try {
185
+ fs.writeFileSync(this.portFilePath, String(this._port), "utf-8");
186
+ } catch (err: any) {
187
+ console.error(
188
+ `[hook-bridge] Warning: could not write port file: ${err.message}`,
189
+ );
190
+ }
191
+
192
+ console.error(`[hook-bridge] Listening on 127.0.0.1:${this._port}`);
193
+ console.error(`[hook-bridge] Port file: ${this.portFilePath}`);
194
+ resolve(this._port);
195
+ });
196
+ });
197
+ }
198
+
199
+ /** Shut down the HTTP server and remove the port file. */
200
+ async destroy(): Promise<void> {
201
+ if (this.httpServer) {
202
+ await new Promise<void>((resolve) => {
203
+ this.httpServer!.close(() => resolve());
204
+ });
205
+ this.httpServer = null;
206
+ }
207
+
208
+ // Remove port file
209
+ try {
210
+ fs.unlinkSync(this.portFilePath);
211
+ } catch {
212
+ // Already gone or never written
213
+ }
214
+
215
+ this._port = null;
216
+ console.error("[hook-bridge] Shut down");
217
+ }
218
+
219
+ private handleRequest(
220
+ req: http.IncomingMessage,
221
+ res: http.ServerResponse,
222
+ ): void {
223
+ // Only accept POST /hook
224
+ if (req.method !== "POST" || req.url !== "/hook") {
225
+ res.writeHead(404);
226
+ res.end();
227
+ return;
228
+ }
229
+
230
+ let body = "";
231
+ req.on("data", (chunk: Buffer) => {
232
+ body += chunk.toString();
233
+ });
234
+ req.on("end", () => {
235
+ res.writeHead(200, { "Content-Type": "application/json" });
236
+ res.end("{}");
237
+
238
+ try {
239
+ const payload = JSON.parse(body);
240
+ this.routeEvent(payload);
241
+ } catch {
242
+ // Invalid JSON — ignore silently (fire-and-forget)
243
+ }
244
+ });
245
+ }
246
+
247
+ private routeEvent(payload: Record<string, any>): void {
248
+ const event = payload.hook_event_name;
249
+ switch (event) {
250
+ case "UserPromptSubmit":
251
+ this.onUserPromptSubmit();
252
+ break;
253
+ case "PreToolUse":
254
+ this.onPreToolUse(payload);
255
+ break;
256
+ case "PostToolUse":
257
+ this.onPostToolUse(payload);
258
+ break;
259
+ case "SubagentStart":
260
+ this.onSubagentStart(payload);
261
+ break;
262
+ case "SubagentStop":
263
+ this.onSubagentStop();
264
+ break;
265
+ case "Stop":
266
+ this.onStop();
267
+ break;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * UserPromptSubmit — intentionally a NO-OP for channel-driven turns.
273
+ *
274
+ * The MCP dispatch (`_beginTurn` + `setAutoStatus('thinking')`) runs FIRST
275
+ * when a channel message arrives, then the notification is injected into the
276
+ * Claude Code session, which fires THIS hook. If we cleared status/turn here
277
+ * we'd wipe the "thinking" turn the dispatch just set up — killing the
278
+ * incantation phrase AND the typing heartbeat. Each new turn already resets
279
+ * cleanly via `_beginTurn` (fresh turnId + empty toolHistory), so there's
280
+ * nothing to clear here.
281
+ */
282
+ private onUserPromptSubmit(): void {
283
+ // no-op — see doc comment.
284
+ }
285
+
286
+ private onPreToolUse(payload: Record<string, any>): void {
287
+ const toolName: string = payload.tool_name ?? "";
288
+ // Skip Abracadabra MCP tools — they set awareness themselves
289
+ if (toolName.startsWith("mcp__abracadabra__")) return;
290
+
291
+ const toolInput = payload.tool_input ?? {};
292
+ const mapped = mapToolCall(toolName, toolInput);
293
+ if (mapped) {
294
+ this.server.setActiveToolCall(mapped);
295
+ this.server.setAutoStatus("working");
296
+ }
297
+ }
298
+
299
+ private onPostToolUse(payload: Record<string, any>): void {
300
+ // Clear the live tool pill for EVERY tool (native + MCP) when it
301
+ // finishes. This is what flips the inline card from running (spinner) →
302
+ // done (check) in the dashboard, and — because no tool is "active"
303
+ // between calls — lets the typing-dots show while Claude composes its
304
+ // reply. The completed call stays in the persistent toolHistory; only
305
+ // the transient `activeToolCall` pill is cleared. Status is left intact
306
+ // (the turn is still live) so the dashboard knows Claude is still working.
307
+ this.server.setActiveToolCall(null);
308
+ }
309
+
310
+ private onSubagentStart(payload: Record<string, any>): void {
311
+ const agentType: string = payload.agent_type ?? "agent";
312
+ this.server.setActiveToolCall({ name: "subagent", target: agentType });
313
+ this.server.setAutoStatus("thinking");
314
+ }
315
+
316
+ private onSubagentStop(): void {
317
+ // No-op — the parent agent continues working; the next Pre/PostToolUse or
318
+ // Stop will update state. Previously this reset status to 'thinking',
319
+ // which kept the auto-clear timer resetting forever.
320
+ }
321
+
322
+ private onStop(): void {
323
+ this.server.setAutoStatus(null);
324
+ this.server.setActiveToolCall(null);
325
+ }
218
326
  }