@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/abracadabra-mcp.cjs +10134 -10006
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +10206 -10078
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/dist/index.d.ts +29 -2
- package/package.json +2 -2
- package/src/hook-bridge.ts +305 -197
- package/src/index.ts +150 -136
- package/src/server.ts +1160 -940
- package/src/tools/channel.ts +138 -98
- package/src/tools/hooks.ts +44 -35
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 =
|
|
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.
|
|
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.
|
|
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'"
|
package/src/hook-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
import * as fs from
|
|
11
|
-
import * as
|
|
12
|
-
import * as
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
130
|
+
if (!filePath) return undefined;
|
|
131
|
+
return path.basename(filePath);
|
|
50
132
|
}
|
|
51
133
|
|
|
52
134
|
function hostname(url: string | undefined): string | undefined {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
}
|