@popoverinstall/cli 0.8.1 → 0.9.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/CHANGELOG.md +147 -78
- package/LICENSE +21 -21
- package/README.md +142 -141
- package/dist/config-command.d.ts +2 -0
- package/dist/config-command.d.ts.map +1 -0
- package/dist/config-command.js +80 -0
- package/dist/config-command.js.map +1 -0
- package/dist/cursor-hooks.d.ts +18 -0
- package/dist/cursor-hooks.d.ts.map +1 -0
- package/dist/cursor-hooks.js +105 -0
- package/dist/cursor-hooks.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -25
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +44 -0
- package/dist/keys.js.map +1 -0
- package/dist/repo-scan.d.ts +130 -0
- package/dist/repo-scan.d.ts.map +1 -0
- package/dist/repo-scan.js +281 -0
- package/dist/repo-scan.js.map +1 -0
- package/dist/repos.d.ts +180 -0
- package/dist/repos.d.ts.map +1 -0
- package/dist/repos.js +1002 -0
- package/dist/repos.js.map +1 -0
- package/dist/snapshot.d.ts +35 -0
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +16 -16
- package/dist/snapshot.js.map +1 -1
- package/dist/vaults.d.ts +276 -0
- package/dist/vaults.d.ts.map +1 -0
- package/dist/vaults.js +1224 -0
- package/dist/vaults.js.map +1 -0
- package/package.json +47 -47
- package/plugin/.claude-plugin/plugin.json +19 -19
- package/plugin/.mcp.json +9 -9
- package/plugin/README.md +84 -77
- package/plugin/commands/ask.md +65 -65
- package/plugin/commands/fork.md +119 -119
- package/plugin/commands/repos.md +107 -0
- package/plugin/commands/team.md +60 -60
- package/plugin/commands/tell.md +66 -66
- package/plugin/commands/vault.md +173 -0
- package/plugin/hooks/hooks.json +111 -111
- package/plugin/mcp/index.mjs +585 -355
- package/plugin/scripts/_ipc.mjs +146 -146
- package/plugin/scripts/announce-roster.mjs +141 -141
- package/plugin/scripts/deliver-messages.mjs +77 -77
- package/plugin/scripts/emit-event.mjs +44 -44
- package/plugin/scripts/ensure-daemon.mjs +156 -156
- package/plugin/scripts/roster.mjs +52 -52
- package/plugin/skills/popover/SKILL.md +175 -168
package/plugin/mcp/index.mjs
CHANGED
|
@@ -1,355 +1,585 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Popover MCP server — lets an agent see and ask its teammates' agents.
|
|
3
|
-
//
|
|
4
|
-
// Implemented directly against the JSON-RPC stdio protocol rather than the MCP SDK. The
|
|
5
|
-
// plugin is distributed through a marketplace and installed into a cache directory where
|
|
6
|
-
// dependency installation is conditional and can fail; a server with zero dependencies
|
|
7
|
-
// simply cannot fail that way. The protocol surface we need is small — initialize,
|
|
8
|
-
// tools/list, tools/call — so the SDK buys us little here.
|
|
9
|
-
//
|
|
10
|
-
// All real work happens in the daemon. This process is a thin bridge to it.
|
|
11
|
-
|
|
12
|
-
import { readFileSync } from "node:fs";
|
|
13
|
-
import { callerSessionId, request } from "../scripts/_ipc.mjs";
|
|
14
|
-
|
|
15
|
-
const PROTOCOL_VERSION = "2025-06-18";
|
|
16
|
-
|
|
17
|
-
// Read from the plugin manifest, which Claude Code copies into its cache alongside this
|
|
18
|
-
// file — so the relative path survives the severing copy that breaks everything else.
|
|
19
|
-
const SERVER_INFO = {
|
|
20
|
-
name: "popover",
|
|
21
|
-
version: JSON.parse(
|
|
22
|
-
readFileSync(new URL("../.claude-plugin/plugin.json", import.meta.url), "utf8"),
|
|
23
|
-
).version,
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const TOOLS = [
|
|
28
|
-
{
|
|
29
|
-
name: "team_list",
|
|
30
|
-
description:
|
|
31
|
-
"List your teammates' currently-active Claude Code agents in THIS repo: who owns " +
|
|
32
|
-
"each one, what it is doing right now, and its short handle (e.g. B1). Only agents " +
|
|
33
|
-
"working in the same repository as you are visible — agents your teammates are " +
|
|
34
|
-
"running in other repos are not listed and cannot be asked. Use this before " +
|
|
35
|
-
"team_ask to find out which agent to ask. Read-only.",
|
|
36
|
-
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
37
|
-
annotations: { readOnlyHint: true, openWorldHint: true, title: "List team agents" },
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
name: "team_ask",
|
|
41
|
-
description:
|
|
42
|
-
"Ask a teammate's Claude Code agent a question and wait for the answer. A " +
|
|
43
|
-
"read-only copy of that agent answers from its full accumulated context; the " +
|
|
44
|
-
"teammate's live session is never interrupted or modified. Use this when a " +
|
|
45
|
-
"teammate's agent already knows something that would take you a long time to " +
|
|
46
|
-
"work out — why a decision was made, what they already ruled out, how a service " +
|
|
47
|
-
"they own behaves. Only agents working in the same repository as you can be asked. " +
|
|
48
|
-
"Answering costs the teammate tokens and takes up to ~90s, so ask one well-formed " +
|
|
49
|
-
"question rather than several exploratory ones.",
|
|
50
|
-
inputSchema: {
|
|
51
|
-
type: "object",
|
|
52
|
-
properties: {
|
|
53
|
-
target: {
|
|
54
|
-
type: "string",
|
|
55
|
-
description:
|
|
56
|
-
"Which agent to ask: a handle from team_list such as 'B1', or a teammate's " +
|
|
57
|
-
"name, or a repo name.",
|
|
58
|
-
},
|
|
59
|
-
question: {
|
|
60
|
-
type: "string",
|
|
61
|
-
description:
|
|
62
|
-
"A self-contained question. The answering agent has none of your " +
|
|
63
|
-
"conversation's context, so expand pronouns and vague references.",
|
|
64
|
-
},
|
|
65
|
-
timeout_seconds: {
|
|
66
|
-
type: "number",
|
|
67
|
-
description: "How long to wait for an answer. Default 90, max 300.",
|
|
68
|
-
minimum: 5,
|
|
69
|
-
maximum: 300,
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
required: ["target", "question"],
|
|
73
|
-
additionalProperties: false,
|
|
74
|
-
},
|
|
75
|
-
annotations: { readOnlyHint: true, openWorldHint: true, title: "Ask a teammate's agent" },
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
name: "team_tell",
|
|
79
|
-
description:
|
|
80
|
-
"Send a short heads-up to a teammate's agent working in this repo. Unlike team_ask " +
|
|
81
|
-
"there is no answer: the message is delivered into that agent's context before its " +
|
|
82
|
-
"next prompt, and nothing comes back. Use it for facts about shared state that would " +
|
|
83
|
-
"otherwise cause a collision — \"the migration is applied on prod\", \"I am rewriting " +
|
|
84
|
-
"globals.css, leave it alone\". Do not use it to give another agent instructions or " +
|
|
85
|
-
"assign it work: the receiving agent is told to treat your message as information " +
|
|
86
|
-
"from outside its conversation, not as a directive, and its user is notified that you " +
|
|
87
|
-
"sent it. Only agents in the same repository can be told, and you are limited to 10 " +
|
|
88
|
-
"messages an hour to any one agent.",
|
|
89
|
-
inputSchema: {
|
|
90
|
-
type: "object",
|
|
91
|
-
properties: {
|
|
92
|
-
target: {
|
|
93
|
-
type: "string",
|
|
94
|
-
description:
|
|
95
|
-
"Which agent to tell: a handle from team_list such as 'B1', or a teammate's " +
|
|
96
|
-
"name, or a repo name.",
|
|
97
|
-
},
|
|
98
|
-
message: {
|
|
99
|
-
type: "string",
|
|
100
|
-
description:
|
|
101
|
-
"The heads-up, in one or two sentences. It arrives with no context of its own, " +
|
|
102
|
-
"so name the thing you are talking about rather than referring to it.",
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
required: ["target", "message"],
|
|
106
|
-
additionalProperties: false,
|
|
107
|
-
},
|
|
108
|
-
// Not read-only: this writes into someone else's context and notifies a person.
|
|
109
|
-
annotations: { readOnlyHint: false, openWorldHint: true, title: "Tell a teammate's agent" },
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
process.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Popover MCP server — lets an agent see and ask its teammates' agents.
|
|
3
|
+
//
|
|
4
|
+
// Implemented directly against the JSON-RPC stdio protocol rather than the MCP SDK. The
|
|
5
|
+
// plugin is distributed through a marketplace and installed into a cache directory where
|
|
6
|
+
// dependency installation is conditional and can fail; a server with zero dependencies
|
|
7
|
+
// simply cannot fail that way. The protocol surface we need is small — initialize,
|
|
8
|
+
// tools/list, tools/call — so the SDK buys us little here.
|
|
9
|
+
//
|
|
10
|
+
// All real work happens in the daemon. This process is a thin bridge to it.
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { callerSessionId, request } from "../scripts/_ipc.mjs";
|
|
14
|
+
|
|
15
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
16
|
+
|
|
17
|
+
// Read from the plugin manifest, which Claude Code copies into its cache alongside this
|
|
18
|
+
// file — so the relative path survives the severing copy that breaks everything else.
|
|
19
|
+
const SERVER_INFO = {
|
|
20
|
+
name: "popover",
|
|
21
|
+
version: JSON.parse(
|
|
22
|
+
readFileSync(new URL("../.claude-plugin/plugin.json", import.meta.url), "utf8"),
|
|
23
|
+
).version,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
const TOOLS = [
|
|
28
|
+
{
|
|
29
|
+
name: "team_list",
|
|
30
|
+
description:
|
|
31
|
+
"List your teammates' currently-active Claude Code agents in THIS repo: who owns " +
|
|
32
|
+
"each one, what it is doing right now, and its short handle (e.g. B1). Only agents " +
|
|
33
|
+
"working in the same repository as you are visible — agents your teammates are " +
|
|
34
|
+
"running in other repos are not listed and cannot be asked. Use this before " +
|
|
35
|
+
"team_ask to find out which agent to ask. Read-only.",
|
|
36
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
37
|
+
annotations: { readOnlyHint: true, openWorldHint: true, title: "List team agents" },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "team_ask",
|
|
41
|
+
description:
|
|
42
|
+
"Ask a teammate's Claude Code agent a question and wait for the answer. A " +
|
|
43
|
+
"read-only copy of that agent answers from its full accumulated context; the " +
|
|
44
|
+
"teammate's live session is never interrupted or modified. Use this when a " +
|
|
45
|
+
"teammate's agent already knows something that would take you a long time to " +
|
|
46
|
+
"work out — why a decision was made, what they already ruled out, how a service " +
|
|
47
|
+
"they own behaves. Only agents working in the same repository as you can be asked. " +
|
|
48
|
+
"Answering costs the teammate tokens and takes up to ~90s, so ask one well-formed " +
|
|
49
|
+
"question rather than several exploratory ones.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
target: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description:
|
|
56
|
+
"Which agent to ask: a handle from team_list such as 'B1', or a teammate's " +
|
|
57
|
+
"name, or a repo name.",
|
|
58
|
+
},
|
|
59
|
+
question: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description:
|
|
62
|
+
"A self-contained question. The answering agent has none of your " +
|
|
63
|
+
"conversation's context, so expand pronouns and vague references.",
|
|
64
|
+
},
|
|
65
|
+
timeout_seconds: {
|
|
66
|
+
type: "number",
|
|
67
|
+
description: "How long to wait for an answer. Default 90, max 300.",
|
|
68
|
+
minimum: 5,
|
|
69
|
+
maximum: 300,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
required: ["target", "question"],
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
},
|
|
75
|
+
annotations: { readOnlyHint: true, openWorldHint: true, title: "Ask a teammate's agent" },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "team_tell",
|
|
79
|
+
description:
|
|
80
|
+
"Send a short heads-up to a teammate's agent working in this repo. Unlike team_ask " +
|
|
81
|
+
"there is no answer: the message is delivered into that agent's context before its " +
|
|
82
|
+
"next prompt, and nothing comes back. Use it for facts about shared state that would " +
|
|
83
|
+
"otherwise cause a collision — \"the migration is applied on prod\", \"I am rewriting " +
|
|
84
|
+
"globals.css, leave it alone\". Do not use it to give another agent instructions or " +
|
|
85
|
+
"assign it work: the receiving agent is told to treat your message as information " +
|
|
86
|
+
"from outside its conversation, not as a directive, and its user is notified that you " +
|
|
87
|
+
"sent it. Only agents in the same repository can be told, and you are limited to 10 " +
|
|
88
|
+
"messages an hour to any one agent.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
target: {
|
|
93
|
+
type: "string",
|
|
94
|
+
description:
|
|
95
|
+
"Which agent to tell: a handle from team_list such as 'B1', or a teammate's " +
|
|
96
|
+
"name, or a repo name.",
|
|
97
|
+
},
|
|
98
|
+
message: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description:
|
|
101
|
+
"The heads-up, in one or two sentences. It arrives with no context of its own, " +
|
|
102
|
+
"so name the thing you are talking about rather than referring to it.",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
required: ["target", "message"],
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
},
|
|
108
|
+
// Not read-only: this writes into someone else's context and notifies a person.
|
|
109
|
+
annotations: { readOnlyHint: false, openWorldHint: true, title: "Tell a teammate's agent" },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "vault_search",
|
|
113
|
+
description:
|
|
114
|
+
"Search your team's vaults. A vault is a conversation somebody on your team froze " +
|
|
115
|
+
"deliberately and published: it does not expire, it is not a live session, and its " +
|
|
116
|
+
"author does not have to be online — or still at the company — for you to use it. " +
|
|
117
|
+
"This searches the index only. It is free, it runs no model, it resumes nothing, and " +
|
|
118
|
+
"it costs neither you nor anybody else a token, so use it freely. Each result carries " +
|
|
119
|
+
"the questions that vault says it can answer, when it was frozen, who froze it, and " +
|
|
120
|
+
"how much of the code it discussed has changed since — a vault whose files nobody has " +
|
|
121
|
+
"touched is current however old it is, and one whose files have churned is suspect " +
|
|
122
|
+
"even if it is recent. Unlike team_list this is not limited to agents in your repo or " +
|
|
123
|
+
"to people who are working right now: every vault on every team you belong to is " +
|
|
124
|
+
"searchable. ALWAYS call this before vault_ask, and read what comes back: asking is " +
|
|
125
|
+
"the expensive half and this is how you spend it on the right vault.",
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
query: {
|
|
130
|
+
type: "string",
|
|
131
|
+
description:
|
|
132
|
+
"What you want to know, phrased as a question. Vaults are indexed by the " +
|
|
133
|
+
"questions they can answer rather than by a summary, so a question matches far " +
|
|
134
|
+
"better than keywords do. Omit it to list the most recently frozen vaults.",
|
|
135
|
+
},
|
|
136
|
+
repo: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description:
|
|
139
|
+
"Narrow to one repo label. Display-level filtering only — vaults are visible " +
|
|
140
|
+
"across every repo your team works in, so leave this off unless the user asked " +
|
|
141
|
+
"for one repo specifically.",
|
|
142
|
+
},
|
|
143
|
+
limit: {
|
|
144
|
+
type: "number",
|
|
145
|
+
description: "How many to return. Default 20, max 50.",
|
|
146
|
+
minimum: 1,
|
|
147
|
+
maximum: 50,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
additionalProperties: false,
|
|
151
|
+
},
|
|
152
|
+
annotations: { readOnlyHint: true, openWorldHint: true, title: "Search team vaults" },
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "vault_ask",
|
|
156
|
+
description:
|
|
157
|
+
"Ask one vault one question and wait for the answer. **This costs a full context " +
|
|
158
|
+
"load and real money** — the frozen conversation is resumed in its entirety on THIS " +
|
|
159
|
+
"machine, on your user's account, at roughly $0.50 and up to 90 seconds per call. " +
|
|
160
|
+
"Call vault_search first and ask the one vault that fits; never fan out across " +
|
|
161
|
+
"several to see which answers best, and never call this on a vault you have not seen " +
|
|
162
|
+
"in a search result. The vault answers from its frozen context and has no tools at " +
|
|
163
|
+
"all: it cannot read a file, run a command, or look at the repository as it stands " +
|
|
164
|
+
"today, which is deliberate — it is authoritative about what was decided and why, and " +
|
|
165
|
+
"explicitly unreliable about what the code currently looks like. Report what comes " +
|
|
166
|
+
"back as the vault's claim, attributed and dated, never as a bare fact merged into " +
|
|
167
|
+
"your own reasoning: a vault can hold a hypothesis the team later disproved. Nothing " +
|
|
168
|
+
"you do here reaches the vault's author, changes the vault, or costs them anything.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: "object",
|
|
171
|
+
properties: {
|
|
172
|
+
vault_id: {
|
|
173
|
+
type: "string",
|
|
174
|
+
description: "The `id` of a vault from a vault_search result. Never guess one.",
|
|
175
|
+
},
|
|
176
|
+
question: {
|
|
177
|
+
type: "string",
|
|
178
|
+
description:
|
|
179
|
+
"A self-contained question. The vault has none of your conversation and has " +
|
|
180
|
+
"been frozen for weeks or months, so expand every pronoun, name files by path, " +
|
|
181
|
+
"and ask about decisions and reasoning rather than about the current code.",
|
|
182
|
+
},
|
|
183
|
+
timeout_seconds: {
|
|
184
|
+
type: "number",
|
|
185
|
+
description: "How long to wait for an answer. Default 90, max 300.",
|
|
186
|
+
minimum: 5,
|
|
187
|
+
maximum: 300,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
required: ["vault_id", "question"],
|
|
191
|
+
additionalProperties: false,
|
|
192
|
+
},
|
|
193
|
+
// Read-only: it resumes a copy and writes nothing back. It is not free, which the
|
|
194
|
+
// description says at length, because an annotation cannot say "and it costs money".
|
|
195
|
+
annotations: { readOnlyHint: true, openWorldHint: true, title: "Ask a team vault" },
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Tool implementations
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
async function callTeamList() {
|
|
204
|
+
// Sent so the daemon can mark which line is this agent's own, and so it can scope the
|
|
205
|
+
// roster to the repo this session is working in. Absent outside a session, in which case
|
|
206
|
+
// the roster comes back unmarked rather than failing.
|
|
207
|
+
const reply = await request(
|
|
208
|
+
{
|
|
209
|
+
t: "roster",
|
|
210
|
+
id: rpcId(),
|
|
211
|
+
refresh: true,
|
|
212
|
+
...(callerSessionId() ? { fromSessionId: callerSessionId() } : {}),
|
|
213
|
+
// Fallback for the narrow window where the daemon has not seen this session yet. The
|
|
214
|
+
// session is authoritative when known; this is only ever consulted when it is not.
|
|
215
|
+
cwd: process.cwd(),
|
|
216
|
+
},
|
|
217
|
+
{ timeoutMs: 15000 },
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
if (!reply) return errorText(daemonDownMessage());
|
|
221
|
+
if (reply.t === "error") return errorText(explain(reply));
|
|
222
|
+
if (reply.t !== "roster.ok") return errorText("The popover daemon returned an unexpected response.");
|
|
223
|
+
|
|
224
|
+
if (!reply.entries || reply.entries.length === 0) {
|
|
225
|
+
// The daemon's own text, because only it knows whether the answer is "nobody on your
|
|
226
|
+
// team is working" or "nobody is working in this repo" — and those lead a model to say
|
|
227
|
+
// very different things.
|
|
228
|
+
return text(
|
|
229
|
+
reply.rendered ||
|
|
230
|
+
"No teammates currently have active Claude Code sessions. Nobody is available to ask right now.",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
// Returned as text rather than structuredContent on purpose: setting
|
|
234
|
+
// structuredContent suppresses text blocks, and the rendered table is both easier for
|
|
235
|
+
// the model to read and directly presentable to the user.
|
|
236
|
+
return text(reply.rendered);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function callTeamAsk(args) {
|
|
240
|
+
const target = typeof args?.target === "string" ? args.target.trim() : "";
|
|
241
|
+
const question = typeof args?.question === "string" ? args.question.trim() : "";
|
|
242
|
+
|
|
243
|
+
if (!target) return errorText("`target` is required — call team_list to see the options.");
|
|
244
|
+
if (!question) return errorText("`question` is required.");
|
|
245
|
+
|
|
246
|
+
const timeoutSeconds = clamp(Number(args?.timeout_seconds) || 90, 5, 300);
|
|
247
|
+
|
|
248
|
+
const reply = await request(
|
|
249
|
+
{
|
|
250
|
+
t: "ask",
|
|
251
|
+
id: rpcId(),
|
|
252
|
+
req: {
|
|
253
|
+
target,
|
|
254
|
+
question,
|
|
255
|
+
timeoutSeconds,
|
|
256
|
+
// Lets the audit log show that an agent asked, not a human, and scopes the target
|
|
257
|
+
// to this session's repo.
|
|
258
|
+
...(callerSessionId() ? { fromSessionId: callerSessionId() } : {}),
|
|
259
|
+
},
|
|
260
|
+
cwd: process.cwd(),
|
|
261
|
+
},
|
|
262
|
+
// Outlast the daemon's own wait so its specific error wins over a generic timeout.
|
|
263
|
+
{ timeoutMs: timeoutSeconds * 1000 + 15000 },
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
if (!reply) return errorText(daemonDownMessage());
|
|
267
|
+
if (reply.t === "error") return errorText(explain(reply));
|
|
268
|
+
if (reply.t !== "ask.ok") return errorText("The popover daemon returned an unexpected response.");
|
|
269
|
+
|
|
270
|
+
const { answer } = reply;
|
|
271
|
+
const by = answer.answeredBy;
|
|
272
|
+
const attribution = by ? `${by.handle} (${by.ownerName}, ${by.repo})` : "a teammate's agent";
|
|
273
|
+
const cost =
|
|
274
|
+
answer.costUsd != null && answer.durationMs != null
|
|
275
|
+
? `\n\n_(answered in ${(answer.durationMs / 1000).toFixed(1)}s, cost $${answer.costUsd.toFixed(3)} on their account)_`
|
|
276
|
+
: "";
|
|
277
|
+
|
|
278
|
+
return text(`Answer from ${attribution}:\n\n${answer.answer}${cost}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function callTeamTell(args) {
|
|
282
|
+
const target = typeof args?.target === "string" ? args.target.trim() : "";
|
|
283
|
+
const message = typeof args?.message === "string" ? args.message.trim() : "";
|
|
284
|
+
|
|
285
|
+
if (!target) return errorText("`target` is required — call team_list to see the options.");
|
|
286
|
+
if (!message) return errorText("`message` is required.");
|
|
287
|
+
|
|
288
|
+
const reply = await request(
|
|
289
|
+
{
|
|
290
|
+
t: "tell",
|
|
291
|
+
id: rpcId(),
|
|
292
|
+
req: {
|
|
293
|
+
target,
|
|
294
|
+
message,
|
|
295
|
+
...(callerSessionId() ? { fromSessionId: callerSessionId() } : {}),
|
|
296
|
+
},
|
|
297
|
+
cwd: process.cwd(),
|
|
298
|
+
},
|
|
299
|
+
{ timeoutMs: 15000 },
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (!reply) return errorText(daemonDownMessage());
|
|
303
|
+
if (reply.t === "error") return errorText(explain(reply));
|
|
304
|
+
if (reply.t !== "tell.ok") return errorText("The popover daemon returned an unexpected response.");
|
|
305
|
+
|
|
306
|
+
// Says delivered-to, never read-by: a sender is deliberately told nothing about what
|
|
307
|
+
// happened to their message afterwards.
|
|
308
|
+
return text(
|
|
309
|
+
`Sent to ${reply.handle} (${reply.ownerName}). It will reach that agent before its next ` +
|
|
310
|
+
`prompt; there is no reply.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Vaults — the cheap half and the costly half, deliberately two tools
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
async function callVaultSearch(args) {
|
|
319
|
+
const query = typeof args?.query === "string" ? args.query.trim() : "";
|
|
320
|
+
const repo = typeof args?.repo === "string" ? args.repo.trim() : "";
|
|
321
|
+
const limit = clamp(Number(args?.limit) || 20, 1, 50);
|
|
322
|
+
|
|
323
|
+
const reply = await request(
|
|
324
|
+
{
|
|
325
|
+
t: "vaults",
|
|
326
|
+
id: rpcId(),
|
|
327
|
+
req: {
|
|
328
|
+
limit,
|
|
329
|
+
...(query ? { query } : {}),
|
|
330
|
+
...(repo ? { repo } : {}),
|
|
331
|
+
},
|
|
332
|
+
// Not a filter. The daemon uses it to diff each vault's files against the tree in front
|
|
333
|
+
// of you, which is what the staleness figure in each result is.
|
|
334
|
+
cwd: process.cwd(),
|
|
335
|
+
},
|
|
336
|
+
{ timeoutMs: 20000 },
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
if (!reply) return errorText(daemonDownMessage());
|
|
340
|
+
if (reply.t === "error") return errorText(explain(reply));
|
|
341
|
+
if (reply.t !== "vaults.ok") return errorText("The popover daemon returned an unexpected response.");
|
|
342
|
+
|
|
343
|
+
const vaults = reply.vaults ?? [];
|
|
344
|
+
if (vaults.length === 0) {
|
|
345
|
+
return text(
|
|
346
|
+
query
|
|
347
|
+
? `No vault on your teams answers anything like "${query}". Nothing was spent looking. ` +
|
|
348
|
+
"Do not call vault_ask — there is no vault to ask."
|
|
349
|
+
: "Your teams have not published any vaults yet.",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const lines = vaults.map((vault, index) => {
|
|
354
|
+
const facts = [
|
|
355
|
+
vault.authorLabel,
|
|
356
|
+
`frozen ${friendlyDate(vault.frozenAt)}`,
|
|
357
|
+
vault.repoLabel ? `repo ${vault.repoLabel}` : null,
|
|
358
|
+
vault.noteCount > 0 ? `${vault.noteCount} note${vault.noteCount === 1 ? "" : "s"}` : null,
|
|
359
|
+
describeStaleness(vault.staleness),
|
|
360
|
+
].filter(Boolean);
|
|
361
|
+
|
|
362
|
+
const answers = (vault.answers ?? []).map((a) => ` - ${a}`).join("\n");
|
|
363
|
+
return (
|
|
364
|
+
`${index + 1}. ${vault.title} (${vault.name})\n` +
|
|
365
|
+
` id: ${vault.id}\n` +
|
|
366
|
+
` ${facts.join(" · ")}\n` +
|
|
367
|
+
(answers ? ` Says it can answer:\n${answers}\n` : "")
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
return text(
|
|
372
|
+
`${vaults.length} vault${vaults.length === 1 ? "" : "s"} on your teams:\n\n` +
|
|
373
|
+
lines.join("\n") +
|
|
374
|
+
"\nThis search cost nothing. Asking one of these costs a full context load and real " +
|
|
375
|
+
"money on your user's account, so pick the single best fit — or none, if none of them " +
|
|
376
|
+
"claims to answer the question.",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function callVaultAsk(args) {
|
|
381
|
+
const vaultId = typeof args?.vault_id === "string" ? args.vault_id.trim() : "";
|
|
382
|
+
const question = typeof args?.question === "string" ? args.question.trim() : "";
|
|
383
|
+
|
|
384
|
+
if (!vaultId) return errorText("`vault_id` is required — call vault_search to find one.");
|
|
385
|
+
if (!question) return errorText("`question` is required.");
|
|
386
|
+
|
|
387
|
+
const timeoutSeconds = clamp(Number(args?.timeout_seconds) || 90, 5, 300);
|
|
388
|
+
|
|
389
|
+
const reply = await request(
|
|
390
|
+
{
|
|
391
|
+
t: "vaultAsk",
|
|
392
|
+
id: rpcId(),
|
|
393
|
+
req: {
|
|
394
|
+
vaultId,
|
|
395
|
+
question,
|
|
396
|
+
timeoutSeconds,
|
|
397
|
+
// Carries the per-session budget. Without it the daemon cannot tell one agent asking
|
|
398
|
+
// six vaults from six agents asking one each.
|
|
399
|
+
...(callerSessionId() ? { fromSessionId: callerSessionId() } : {}),
|
|
400
|
+
},
|
|
401
|
+
cwd: process.cwd(),
|
|
402
|
+
},
|
|
403
|
+
// Outlast the daemon's own wait so its specific error wins over a generic timeout.
|
|
404
|
+
{ timeoutMs: timeoutSeconds * 1000 + 15000 },
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
if (!reply) return errorText(daemonDownMessage());
|
|
408
|
+
if (reply.t === "error") return errorText(explain(reply));
|
|
409
|
+
if (reply.t !== "vaultAsk.ok") return errorText("The popover daemon returned an unexpected response.");
|
|
410
|
+
|
|
411
|
+
const { answer } = reply;
|
|
412
|
+
// Attribution first, and not optional. A human reading a vault's answer supplies the
|
|
413
|
+
// skepticism unprompted; a model will not unless the format forces it, and a vault can hold
|
|
414
|
+
// a hypothesis the team later disproved.
|
|
415
|
+
const at = answer.gitSha ? ` at commit ${answer.gitSha}` : "";
|
|
416
|
+
const cost =
|
|
417
|
+
answer.costUsd != null && answer.durationMs != null
|
|
418
|
+
? ` Answered in ${(answer.durationMs / 1000).toFixed(1)}s, cost $${answer.costUsd.toFixed(3)} on your own account.`
|
|
419
|
+
: "";
|
|
420
|
+
|
|
421
|
+
return text(
|
|
422
|
+
`The "${answer.title}" vault, frozen ${friendlyDate(answer.frozenAt)}${at}, says:\n\n` +
|
|
423
|
+
`${answer.answer}\n\n` +
|
|
424
|
+
`_(That is a frozen conversation, not the current code. Treat it as authoritative ` +
|
|
425
|
+
`about what was decided and why, and check anything it says about how the repository ` +
|
|
426
|
+
`looks today. Attribute it to the vault when you pass it on.${cost})_`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** ISO timestamp to something a person reads, without pulling in a formatter. */
|
|
431
|
+
function friendlyDate(iso) {
|
|
432
|
+
const at = new Date(iso);
|
|
433
|
+
if (Number.isNaN(at.getTime())) return iso;
|
|
434
|
+
const months = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
|
|
435
|
+
return `${at.getUTCDate()} ${months[at.getUTCMonth()]} ${at.getUTCFullYear()}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The staleness figure in words.
|
|
440
|
+
*
|
|
441
|
+
* Absent when the daemon had no working tree to diff against, or the vault recorded no paths.
|
|
442
|
+
* Silence is correct there: "0% changed" and "we could not check" are different claims, and
|
|
443
|
+
* only one of them is evidence that the vault is still current.
|
|
444
|
+
*/
|
|
445
|
+
function describeStaleness(staleness) {
|
|
446
|
+
if (typeof staleness !== "number") return null;
|
|
447
|
+
if (staleness === 0) return "none of its files have changed since";
|
|
448
|
+
return `${Math.round(staleness * 100)}% of its files have changed since`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function explain(errorReply) {
|
|
452
|
+
switch (errorReply.code) {
|
|
453
|
+
// The daemon knows whether credentials are missing or merely unusable; relaying its
|
|
454
|
+
// message avoids telling an already signed-in user to sign in again.
|
|
455
|
+
case "not_authenticated":
|
|
456
|
+
case "target_offline":
|
|
457
|
+
case "unknown_target":
|
|
458
|
+
case "timeout":
|
|
459
|
+
case "fork_failed":
|
|
460
|
+
case "bad_request":
|
|
461
|
+
return errorReply.message;
|
|
462
|
+
// A vault that is gone is gone: they are never edited and never expire, so this means the
|
|
463
|
+
// id was wrong or it has been archived. Retrying it, or trying a neighbouring id, cannot
|
|
464
|
+
// help — searching again can.
|
|
465
|
+
case "vault_not_found":
|
|
466
|
+
return `${errorReply.message} Call vault_search again rather than trying another id.`;
|
|
467
|
+
case "cloud_unreachable":
|
|
468
|
+
return `Could not reach the popover backend: ${errorReply.message}`;
|
|
469
|
+
default:
|
|
470
|
+
return errorReply.message || "The request failed.";
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function daemonDownMessage() {
|
|
475
|
+
return (
|
|
476
|
+
"The popover daemon is not running on this machine, so teammate agents are not " +
|
|
477
|
+
"reachable. Start it with `popover daemon start`, or run `popover doctor` to diagnose."
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// JSON-RPC plumbing
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
function text(value) {
|
|
486
|
+
return { content: [{ type: "text", text: value }] };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function errorText(message) {
|
|
490
|
+
// isError tells the model the call failed while still handing it a readable reason,
|
|
491
|
+
// which is far more useful than a transport-level error.
|
|
492
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function clamp(n, lo, hi) {
|
|
496
|
+
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : lo;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let counter = 0;
|
|
500
|
+
function rpcId() {
|
|
501
|
+
counter += 1;
|
|
502
|
+
return `mcp-${process.pid}-${counter}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function send(message) {
|
|
506
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function reply(id, result) {
|
|
510
|
+
send({ jsonrpc: "2.0", id, result });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function replyError(id, code, message) {
|
|
514
|
+
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function handle(message) {
|
|
518
|
+
const { id, method, params } = message;
|
|
519
|
+
|
|
520
|
+
// Notifications have no id and must never be answered.
|
|
521
|
+
const isNotification = id === undefined || id === null;
|
|
522
|
+
|
|
523
|
+
switch (method) {
|
|
524
|
+
case "initialize":
|
|
525
|
+
return reply(id, {
|
|
526
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
527
|
+
capabilities: { tools: {} },
|
|
528
|
+
serverInfo: SERVER_INFO,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
case "notifications/initialized":
|
|
532
|
+
case "notifications/cancelled":
|
|
533
|
+
return;
|
|
534
|
+
|
|
535
|
+
case "ping":
|
|
536
|
+
return reply(id, {});
|
|
537
|
+
|
|
538
|
+
case "tools/list":
|
|
539
|
+
return reply(id, { tools: TOOLS });
|
|
540
|
+
|
|
541
|
+
case "tools/call": {
|
|
542
|
+
const name = params?.name;
|
|
543
|
+
try {
|
|
544
|
+
if (name === "team_list") return reply(id, await callTeamList());
|
|
545
|
+
if (name === "team_ask") return reply(id, await callTeamAsk(params?.arguments ?? {}));
|
|
546
|
+
if (name === "team_tell") return reply(id, await callTeamTell(params?.arguments ?? {}));
|
|
547
|
+
if (name === "vault_search") return reply(id, await callVaultSearch(params?.arguments ?? {}));
|
|
548
|
+
if (name === "vault_ask") return reply(id, await callVaultAsk(params?.arguments ?? {}));
|
|
549
|
+
return replyError(id, -32602, `Unknown tool: ${name}`);
|
|
550
|
+
} catch (err) {
|
|
551
|
+
// A thrown handler must not kill the server; report it as a failed tool call.
|
|
552
|
+
return reply(id, errorText(`popover failed: ${err?.message ?? String(err)}`));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
default:
|
|
557
|
+
if (!isNotification) replyError(id, -32601, `Method not found: ${method}`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let buffer = "";
|
|
563
|
+
process.stdin.setEncoding("utf8");
|
|
564
|
+
process.stdin.on("data", (chunk) => {
|
|
565
|
+
buffer += chunk;
|
|
566
|
+
let idx = buffer.indexOf("\n");
|
|
567
|
+
while (idx !== -1) {
|
|
568
|
+
const line = buffer.slice(0, idx).trim();
|
|
569
|
+
buffer = buffer.slice(idx + 1);
|
|
570
|
+
if (line) {
|
|
571
|
+
let message;
|
|
572
|
+
try {
|
|
573
|
+
message = JSON.parse(line);
|
|
574
|
+
} catch {
|
|
575
|
+
idx = buffer.indexOf("\n");
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
void handle(message);
|
|
579
|
+
}
|
|
580
|
+
idx = buffer.indexOf("\n");
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
process.stdin.on("end", () => process.exit(0));
|
|
585
|
+
process.on("uncaughtException", () => {});
|