@kammcs/buildit-mcp 0.2.0 → 0.7.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 +116 -0
- package/README.md +252 -32
- package/SECURITY.md +1 -1
- package/dist/api/client.js +5 -1
- package/dist/api/generated/operations.js +661 -4
- package/dist/api/generated/schemas.js +680 -2
- package/dist/api/generated/strict.js +799 -2
- package/dist/channel/api.js +128 -0
- package/dist/channel/approvals.js +259 -0
- package/dist/channel/item-events.js +138 -0
- package/dist/channel/lock.js +181 -0
- package/dist/channel/loop.js +776 -0
- package/dist/channel/mask.js +365 -0
- package/dist/channel/threads.js +60 -0
- package/dist/channel/tool-ring.js +124 -0
- package/dist/channel/typing.js +150 -0
- package/dist/channel/waiters.js +229 -0
- package/dist/channel/workers.js +66 -0
- package/dist/cli/claude-config.js +100 -0
- package/dist/cli/commands.js +410 -0
- package/dist/config.js +329 -6
- package/dist/errors.js +38 -0
- package/dist/index.js +122 -6
- package/dist/log.js +3 -2
- package/dist/secrets/keychain.js +273 -0
- package/dist/server.js +53 -5
- package/dist/tools/approve.js +791 -0
- package/dist/tools/ask.js +898 -0
- package/dist/tools/chat.js +98 -1
- package/dist/tools/items-read.js +5 -3
- package/dist/tools/items-write.js +66 -15
- package/dist/tools/plans.js +221 -14
- package/dist/tools/reply.js +138 -0
- package/dist/tools/shared.js +10 -1
- package/dist/tools/threads.js +123 -0
- package/dist/tools/whoami.js +17 -3
- package/dist/toolsets/catalog.js +3 -1
- package/dist/toolsets/registry.js +2 -0
- package/dist/transports/stdio.js +260 -6
- package/package.json +5 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "<agent> is writing…" in buildIt.Social while Claude works on a message.
|
|
3
|
+
*
|
|
4
|
+
* When the loop hands one of the owner's messages to Claude, the thread it
|
|
5
|
+
* came in starts "working": the server says the agent is typing there at
|
|
6
|
+
* once (POST /v1/session/typing), and again every 15 seconds (the app shows
|
|
7
|
+
* it for 30). It stops, with one `typing: false`, when for that thread:
|
|
8
|
+
* reply posts, ask posts a question card (the card shows the agent waits),
|
|
9
|
+
* a plan card is posted; for every thread, when the plugin's Stop hook says
|
|
10
|
+
* Claude's turn ended (the turn_ended tool), after 10 minutes without a
|
|
11
|
+
* tool call for it, and on shutdown. When the session ends, the timers
|
|
12
|
+
* just stop (the token no longer works).
|
|
13
|
+
*
|
|
14
|
+
* The 10 minutes count per thread: a tool call that names a thread (its
|
|
15
|
+
* thread_id, or an item's key) is that thread's activity, and a call that
|
|
16
|
+
* names none counts for the working thread only while there is one. Item
|
|
17
|
+
* workers run in parallel through this one server, so each item's thread
|
|
18
|
+
* shows "is writing" while its own worker calls tools, and `work` starts it
|
|
19
|
+
* again when a worker goes on after a reply.
|
|
20
|
+
*
|
|
21
|
+
* It is only a hint: failures are logged, never shown to Claude, and a 429
|
|
22
|
+
* pauses the beats until its Retry-After has passed.
|
|
23
|
+
*/
|
|
24
|
+
import { ApiError } from '../api/client.js';
|
|
25
|
+
import { sessionTyping } from './api.js';
|
|
26
|
+
/** How often a working thread says so again. */
|
|
27
|
+
export const TYPING_EVERY_MS = 15_000;
|
|
28
|
+
/** A thread stops working after this long without a tool call. */
|
|
29
|
+
export const TYPING_IDLE_MS = 10 * 60_000;
|
|
30
|
+
export class TypingIndicator {
|
|
31
|
+
options;
|
|
32
|
+
threads = new Map();
|
|
33
|
+
/** Each working thread's latest activity. */
|
|
34
|
+
lastActivity = new Map();
|
|
35
|
+
everyMs;
|
|
36
|
+
idleMs;
|
|
37
|
+
now;
|
|
38
|
+
/** No beats before this time (after a 429). */
|
|
39
|
+
quietUntil = 0;
|
|
40
|
+
closed = false;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.everyMs = options.everyMs ?? TYPING_EVERY_MS;
|
|
44
|
+
this.idleMs = options.idleMs ?? TYPING_IDLE_MS;
|
|
45
|
+
this.now = options.now ?? Date.now;
|
|
46
|
+
}
|
|
47
|
+
/** The threads working now (tests). */
|
|
48
|
+
get working() {
|
|
49
|
+
return [...this.threads.keys()];
|
|
50
|
+
}
|
|
51
|
+
/** A message for Claude arrived in this thread: it is working on it. */
|
|
52
|
+
start(threadId) {
|
|
53
|
+
if (this.closed)
|
|
54
|
+
return;
|
|
55
|
+
this.lastActivity.set(threadId, this.now());
|
|
56
|
+
const old = this.threads.get(threadId);
|
|
57
|
+
if (old !== undefined)
|
|
58
|
+
clearInterval(old);
|
|
59
|
+
const timer = setInterval(() => {
|
|
60
|
+
this.beat(threadId);
|
|
61
|
+
}, this.everyMs);
|
|
62
|
+
timer.unref();
|
|
63
|
+
this.threads.set(threadId, timer);
|
|
64
|
+
void this.send(threadId, true);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A tool call to this server: Claude is still at work, on that thread
|
|
68
|
+
* when the call names one, else on the only working thread (with
|
|
69
|
+
* several, a call that names none keeps none of them going).
|
|
70
|
+
*/
|
|
71
|
+
activity(threadId) {
|
|
72
|
+
const now = this.now();
|
|
73
|
+
if (threadId !== undefined) {
|
|
74
|
+
if (this.threads.has(threadId))
|
|
75
|
+
this.lastActivity.set(threadId, now);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (this.threads.size !== 1)
|
|
79
|
+
return;
|
|
80
|
+
for (const id of this.threads.keys())
|
|
81
|
+
this.lastActivity.set(id, now);
|
|
82
|
+
}
|
|
83
|
+
/** A worker is at work on this thread: it shows "is writing" again if it had stopped. */
|
|
84
|
+
work(threadId) {
|
|
85
|
+
if (this.closed)
|
|
86
|
+
return;
|
|
87
|
+
if (this.threads.has(threadId)) {
|
|
88
|
+
this.lastActivity.set(threadId, this.now());
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.start(threadId);
|
|
92
|
+
}
|
|
93
|
+
/** Stops one thread (reply, a card posted); nothing when it isn't working. */
|
|
94
|
+
stop(threadId) {
|
|
95
|
+
if (threadId === undefined)
|
|
96
|
+
return;
|
|
97
|
+
const timer = this.threads.get(threadId);
|
|
98
|
+
if (timer === undefined)
|
|
99
|
+
return;
|
|
100
|
+
clearInterval(timer);
|
|
101
|
+
this.threads.delete(threadId);
|
|
102
|
+
this.lastActivity.delete(threadId);
|
|
103
|
+
void this.send(threadId, false);
|
|
104
|
+
}
|
|
105
|
+
/** Stops every thread (the turn ended, or shutdown); the promise settles once they are told. */
|
|
106
|
+
async stopAll() {
|
|
107
|
+
const ids = [...this.threads.keys()];
|
|
108
|
+
for (const timer of this.threads.values())
|
|
109
|
+
clearInterval(timer);
|
|
110
|
+
this.threads.clear();
|
|
111
|
+
this.lastActivity.clear();
|
|
112
|
+
await Promise.all(ids.map((id) => this.send(id, false)));
|
|
113
|
+
}
|
|
114
|
+
/** The session ended: stop every timer, and send nothing more. */
|
|
115
|
+
close() {
|
|
116
|
+
this.closed = true;
|
|
117
|
+
for (const timer of this.threads.values())
|
|
118
|
+
clearInterval(timer);
|
|
119
|
+
this.threads.clear();
|
|
120
|
+
}
|
|
121
|
+
beat(threadId) {
|
|
122
|
+
if (this.now() - (this.lastActivity.get(threadId) ?? 0) >= this.idleMs) {
|
|
123
|
+
this.options.logger.debug('typing stopped: no activity', { thread_id: threadId });
|
|
124
|
+
this.stop(threadId);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (this.now() < this.quietUntil)
|
|
128
|
+
return;
|
|
129
|
+
void this.send(threadId, true);
|
|
130
|
+
}
|
|
131
|
+
async send(threadId, typing) {
|
|
132
|
+
if (this.closed)
|
|
133
|
+
return;
|
|
134
|
+
// A stop is sent even while quiet: the app would otherwise show it until its time runs out.
|
|
135
|
+
if (typing && this.now() < this.quietUntil)
|
|
136
|
+
return;
|
|
137
|
+
try {
|
|
138
|
+
await sessionTyping(this.options.api, { thread_id: threadId, typing }, { tool: 'typing', signal: AbortSignal.timeout(15_000) });
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err instanceof ApiError && err.status === 429) {
|
|
142
|
+
this.quietUntil = this.now() + Math.max(this.everyMs, (err.retryAfterSeconds ?? 60) * 1000);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.options.logger.debug('typing not sent', {
|
|
146
|
+
code: err instanceof ApiError ? err.code : 'error',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** The longest the loop waits for a waiter to handle one event. */
|
|
2
|
+
export const WAITER_STEP_MS = 10_000;
|
|
3
|
+
/** The card message's id that an event is about, if it names one. */
|
|
4
|
+
export function eventMessageId(event) {
|
|
5
|
+
const message = event.data.message;
|
|
6
|
+
if (message === null || typeof message !== 'object')
|
|
7
|
+
return undefined;
|
|
8
|
+
const id = message.id;
|
|
9
|
+
return typeof id === 'string' ? id : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** How many cards' outcomes are remembered for a repeated ask. */
|
|
12
|
+
const REMEMBERED = 500;
|
|
13
|
+
/** A map that forgets its oldest entries past a size. */
|
|
14
|
+
class Bounded {
|
|
15
|
+
map = new Map();
|
|
16
|
+
get(key) {
|
|
17
|
+
return this.map.get(key);
|
|
18
|
+
}
|
|
19
|
+
set(key, value) {
|
|
20
|
+
this.map.delete(key);
|
|
21
|
+
this.map.set(key, value);
|
|
22
|
+
if (this.map.size > REMEMBERED) {
|
|
23
|
+
const oldest = this.map.keys().next().value;
|
|
24
|
+
if (oldest !== undefined)
|
|
25
|
+
this.map.delete(oldest);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The waiters of one session, by card message id (several may wait for one
|
|
31
|
+
* card: the hook can run twice for one question), and what the session's
|
|
32
|
+
* cards became: the answers accepted, and how each card closed. A repeated
|
|
33
|
+
* ask for a card that was already answered returns those answers instead
|
|
34
|
+
* of waiting for events that were handed on (and acknowledged) before.
|
|
35
|
+
*/
|
|
36
|
+
export class CardWaiters {
|
|
37
|
+
stepMs;
|
|
38
|
+
waiters = new Map();
|
|
39
|
+
/** The thread of each card waited on (by message id), as registered. */
|
|
40
|
+
threads = new Map();
|
|
41
|
+
answers = new Bounded();
|
|
42
|
+
closings = new Bounded();
|
|
43
|
+
verdicts = new Bounded();
|
|
44
|
+
ended = false;
|
|
45
|
+
constructor(stepMs = WAITER_STEP_MS) {
|
|
46
|
+
this.stepMs = stepMs;
|
|
47
|
+
}
|
|
48
|
+
/** Whether the session has ended (a new waiter is ended at once). */
|
|
49
|
+
get isEnded() {
|
|
50
|
+
return this.ended;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Registers a waiter for a card, in its thread (undefined: a top-level
|
|
54
|
+
* card); the returned function unregisters it.
|
|
55
|
+
*/
|
|
56
|
+
register(messageId, waiter, threadId) {
|
|
57
|
+
let set = this.waiters.get(messageId);
|
|
58
|
+
if (set === undefined) {
|
|
59
|
+
set = new Set();
|
|
60
|
+
this.waiters.set(messageId, set);
|
|
61
|
+
}
|
|
62
|
+
set.add(waiter);
|
|
63
|
+
if (threadId !== undefined)
|
|
64
|
+
this.threads.set(messageId, threadId);
|
|
65
|
+
if (this.ended)
|
|
66
|
+
waiter.end();
|
|
67
|
+
return () => {
|
|
68
|
+
const current = this.waiters.get(messageId);
|
|
69
|
+
if (current === undefined)
|
|
70
|
+
return;
|
|
71
|
+
current.delete(waiter);
|
|
72
|
+
if (current.size === 0) {
|
|
73
|
+
this.waiters.delete(messageId);
|
|
74
|
+
this.threads.delete(messageId);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
has(messageId) {
|
|
79
|
+
return this.waiters.has(messageId);
|
|
80
|
+
}
|
|
81
|
+
/** How many cards are waited on (tests). */
|
|
82
|
+
get size() {
|
|
83
|
+
return this.waiters.size;
|
|
84
|
+
}
|
|
85
|
+
/** Remembers the answers accepted for a card. */
|
|
86
|
+
rememberAnswers(messageId, answers) {
|
|
87
|
+
this.answers.set(messageId, answers);
|
|
88
|
+
}
|
|
89
|
+
/** The answers accepted for a card, if this process saw them. */
|
|
90
|
+
answersOf(messageId) {
|
|
91
|
+
return this.answers.get(messageId);
|
|
92
|
+
}
|
|
93
|
+
/** Remembers how a tool approval ended (the hook's answer for it). */
|
|
94
|
+
rememberApproval(messageId, verdict) {
|
|
95
|
+
this.verdicts.set(messageId, verdict);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* How a tool approval ended, if this process saw it and it was for this
|
|
99
|
+
* tool and input: a repeated hook call for the same call gets the same
|
|
100
|
+
* answer; anything else is unknown.
|
|
101
|
+
*/
|
|
102
|
+
approvalOf(messageId, toolName, hash) {
|
|
103
|
+
const verdict = this.verdicts.get(messageId);
|
|
104
|
+
return verdict !== undefined && verdict.toolName === toolName && verdict.hash === hash
|
|
105
|
+
? verdict
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
/** How a card closed (its closed_by), if this process saw it. */
|
|
109
|
+
closedBy(messageId) {
|
|
110
|
+
return this.closings.get(messageId);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Hands the event to its card's waiters and waits until they have handled
|
|
114
|
+
* it, or for at most stepMs. A card.closed is remembered first. False when
|
|
115
|
+
* no waiter waits for that card.
|
|
116
|
+
*/
|
|
117
|
+
async deliver(event) {
|
|
118
|
+
const id = eventMessageId(event);
|
|
119
|
+
if (id === undefined)
|
|
120
|
+
return false;
|
|
121
|
+
if (event.type === 'card.closed') {
|
|
122
|
+
const by = event.data.closed_by;
|
|
123
|
+
this.closings.set(id, typeof by === 'string' ? by : 'app');
|
|
124
|
+
}
|
|
125
|
+
const set = this.waiters.get(id);
|
|
126
|
+
if (set === undefined || set.size === 0)
|
|
127
|
+
return false;
|
|
128
|
+
let timer;
|
|
129
|
+
const late = new Promise((resolve) => {
|
|
130
|
+
timer = setTimeout(resolve, this.stepMs);
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
await Promise.race([
|
|
134
|
+
Promise.all([...set].map((w) => w.handle(event).catch(() => undefined))),
|
|
135
|
+
late,
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The session no longer works in this thread (it was unassigned from the
|
|
145
|
+
* item): every card waited on there is remembered as closed, and its
|
|
146
|
+
* waiters stop with `closed`. The server closes the cards itself.
|
|
147
|
+
*/
|
|
148
|
+
closeThread(threadId) {
|
|
149
|
+
let closed = 0;
|
|
150
|
+
for (const [messageId, thread] of [...this.threads]) {
|
|
151
|
+
if (thread !== threadId)
|
|
152
|
+
continue;
|
|
153
|
+
this.closings.set(messageId, 'unassigned');
|
|
154
|
+
for (const waiter of [...(this.waiters.get(messageId) ?? [])]) {
|
|
155
|
+
waiter.close();
|
|
156
|
+
closed++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return closed;
|
|
160
|
+
}
|
|
161
|
+
/** The session ended for good: every waiter is told, and later ones at once. */
|
|
162
|
+
endAll() {
|
|
163
|
+
this.ended = true;
|
|
164
|
+
for (const set of [...this.waiters.values()])
|
|
165
|
+
for (const waiter of [...set])
|
|
166
|
+
waiter.end();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* A waiter that queues its events, for a loop that takes them one at a
|
|
171
|
+
* time (`next`) and says when each is handled.
|
|
172
|
+
*/
|
|
173
|
+
export class CardInbox {
|
|
174
|
+
queue = [];
|
|
175
|
+
wake;
|
|
176
|
+
ended = false;
|
|
177
|
+
closed = false;
|
|
178
|
+
handle(event) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
this.queue.push({ event, done: resolve });
|
|
181
|
+
this.wake?.();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
end() {
|
|
185
|
+
this.ended = true;
|
|
186
|
+
this.wake?.();
|
|
187
|
+
}
|
|
188
|
+
close() {
|
|
189
|
+
this.closed = true;
|
|
190
|
+
this.wake?.();
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The next event, or why there is none: the deadline passed (`timeout`),
|
|
194
|
+
* the signal aborted, the session ended, or the card's thread closed to
|
|
195
|
+
* the session (`closed`). Events already queued come first.
|
|
196
|
+
*/
|
|
197
|
+
async next(deadline, signal, now = Date.now) {
|
|
198
|
+
for (;;) {
|
|
199
|
+
const queued = this.queue.shift();
|
|
200
|
+
if (queued !== undefined)
|
|
201
|
+
return queued;
|
|
202
|
+
if (signal?.aborted)
|
|
203
|
+
return 'aborted';
|
|
204
|
+
if (this.ended)
|
|
205
|
+
return 'session_ended';
|
|
206
|
+
if (this.closed)
|
|
207
|
+
return 'closed';
|
|
208
|
+
const left = deadline - now();
|
|
209
|
+
if (left <= 0)
|
|
210
|
+
return 'timeout';
|
|
211
|
+
await new Promise((resolve) => {
|
|
212
|
+
const timer = setTimeout(done, left);
|
|
213
|
+
function done() {
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
signal?.removeEventListener('abort', done);
|
|
216
|
+
resolve();
|
|
217
|
+
}
|
|
218
|
+
this.wake = done;
|
|
219
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
220
|
+
});
|
|
221
|
+
this.wake = undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Says every queued event was handled (when the waiter stops). */
|
|
225
|
+
drain() {
|
|
226
|
+
for (const d of this.queue.splice(0))
|
|
227
|
+
d.done();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the plugin's AskUserQuestion hook posts a question.
|
|
3
|
+
*
|
|
4
|
+
* The hook can't name a thread, but Claude Code tells it who is asking:
|
|
5
|
+
* its input carries `agent_id` and `agent_type` when the tool call comes
|
|
6
|
+
* from a subagent (both empty for the main agent; checked with Claude Code
|
|
7
|
+
* 2.1.312, where `agent_type` is the plugin-scoped name, such as
|
|
8
|
+
* `buildit:buildit-item-worker`, and `agent_id` the id the Agent tool gave
|
|
9
|
+
* the lead). So:
|
|
10
|
+
*
|
|
11
|
+
* - the main agent (the lead) asks in the direct message: its latest
|
|
12
|
+
* thread there, else a new message;
|
|
13
|
+
* - an item worker asks in its item's thread, found in the lead's table of
|
|
14
|
+
* workers (`.claude/buildit-workers.json` in the folder, which the lead
|
|
15
|
+
* keeps: `{"workers": [{"agent_id", "item", "thread_id"}]}`);
|
|
16
|
+
* - anything else (another subagent, a worker the table doesn't name) is
|
|
17
|
+
* not placed, and the hook lets the terminal ask.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
/** The lead's table of item workers, relative to the folder. */
|
|
22
|
+
export const WORKERS_FILE = join('.claude', 'buildit-workers.json');
|
|
23
|
+
/** The item worker's agent name (the plugin's agents/buildit-item-worker.md). */
|
|
24
|
+
export const ITEM_WORKER_NAME = 'buildit-item-worker';
|
|
25
|
+
/** The largest table read. */
|
|
26
|
+
const WORKERS_FILE_MAX_BYTES = 256 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the hook's agent_type is the plugin's item worker: exactly its
|
|
29
|
+
* name, or the name under this plugin (never another plugin's agent of the
|
|
30
|
+
* same name).
|
|
31
|
+
*/
|
|
32
|
+
export function isItemWorker(agentType) {
|
|
33
|
+
return agentType === ITEM_WORKER_NAME || agentType === `buildit:${ITEM_WORKER_NAME}`;
|
|
34
|
+
}
|
|
35
|
+
/** The lead's entry for the worker with this agent id, if the table names it. */
|
|
36
|
+
export function workerEntry(folder, agentId) {
|
|
37
|
+
const path = join(folder, WORKERS_FILE);
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
if (statSync(path).size > WORKERS_FILE_MAX_BYTES)
|
|
41
|
+
return undefined;
|
|
42
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const list = Array.isArray(parsed)
|
|
48
|
+
? parsed
|
|
49
|
+
: parsed !== null && typeof parsed === 'object'
|
|
50
|
+
? parsed.workers
|
|
51
|
+
: undefined;
|
|
52
|
+
if (!Array.isArray(list))
|
|
53
|
+
return undefined;
|
|
54
|
+
const str = (v) => typeof v === 'string' && v.trim() !== '' && v.length <= 200 ? v.trim() : undefined;
|
|
55
|
+
for (const entry of list) {
|
|
56
|
+
if (entry === null || typeof entry !== 'object')
|
|
57
|
+
continue;
|
|
58
|
+
const e = entry;
|
|
59
|
+
if (str(e.agent_id) !== agentId)
|
|
60
|
+
continue;
|
|
61
|
+
const found = { thread_id: str(e.thread_id), item: str(e.item) };
|
|
62
|
+
if (found.thread_id !== undefined || found.item !== undefined)
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Claude Code is configured to start for a folder, as far as its
|
|
3
|
+
* files show it: for `buildit-mcp status`, which warns when both a channel
|
|
4
|
+
* server added with `claude mcp add` (or in the folder's .mcp.json) and the
|
|
5
|
+
* buildit plugin are set up, since only one of them may be the folder's
|
|
6
|
+
* channel.
|
|
7
|
+
*
|
|
8
|
+
* It only reads: `~/.claude.json` (user and per-project servers), the
|
|
9
|
+
* folder's `.mcp.json`, and the plugin lists (`~/.claude/plugins/
|
|
10
|
+
* installed_plugins.json`, `enabledPlugins` in the user's and the folder's
|
|
11
|
+
* settings). A plugin loaded with --plugin-dir can't be seen. Nothing from
|
|
12
|
+
* these files is printed but the places a server was found.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { folderIdentity } from '../secrets/keychain.js';
|
|
18
|
+
/** The plugin's name, as its manifest names it. */
|
|
19
|
+
export const PLUGIN_NAME = 'buildit';
|
|
20
|
+
function readJson(path) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
24
|
+
? parsed
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function recordOf(v) {
|
|
32
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
33
|
+
? v
|
|
34
|
+
: {};
|
|
35
|
+
}
|
|
36
|
+
/** Whether an mcpServers entry runs buildit-mcp in channel mode. */
|
|
37
|
+
export function isChannelServer(entry) {
|
|
38
|
+
const e = recordOf(entry);
|
|
39
|
+
const args = Array.isArray(e.args)
|
|
40
|
+
? e.args.filter((a) => typeof a === 'string')
|
|
41
|
+
: [];
|
|
42
|
+
const line = [typeof e.command === 'string' ? e.command : '', ...args].join(' ');
|
|
43
|
+
if (!/buildit-mcp/.test(line))
|
|
44
|
+
return false;
|
|
45
|
+
const env = recordOf(e.env);
|
|
46
|
+
return (args.includes('--channel') ||
|
|
47
|
+
/^(1|true|yes|on)$/i.test(typeof env.BUILDIT_CHANNEL === 'string' ? env.BUILDIT_CHANNEL : ''));
|
|
48
|
+
}
|
|
49
|
+
/** The buildit-mcp channel servers configured for the folder outside the plugin. */
|
|
50
|
+
export function channelServers(folder, home = homedir()) {
|
|
51
|
+
const found = [];
|
|
52
|
+
const add = (servers, place) => {
|
|
53
|
+
for (const [name, entry] of Object.entries(recordOf(servers))) {
|
|
54
|
+
if (isChannelServer(entry) && !found.some((f) => f.name === name && f.place === place)) {
|
|
55
|
+
found.push({ name, place });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const user = readJson(join(home, '.claude.json'));
|
|
60
|
+
if (user !== undefined) {
|
|
61
|
+
add(user.mcpServers, '~/.claude.json');
|
|
62
|
+
const id = folderIdentity(folder);
|
|
63
|
+
const project = Object.entries(recordOf(user.projects)).find(([path]) => folderIdentity(path) === id)?.[1];
|
|
64
|
+
add(recordOf(project).mcpServers, '~/.claude.json');
|
|
65
|
+
}
|
|
66
|
+
const local = readJson(join(folder, '.mcp.json'));
|
|
67
|
+
if (local !== undefined)
|
|
68
|
+
add(local.mcpServers, '.mcp.json');
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
/** Where a buildit-mcp channel server is configured for the folder ("~/.claude.json", ".mcp.json"). */
|
|
72
|
+
export function channelServerPlaces(folder, home = homedir()) {
|
|
73
|
+
return [...new Set(channelServers(folder, home).map((s) => s.place))];
|
|
74
|
+
}
|
|
75
|
+
const isPluginKey = (key) => key === PLUGIN_NAME || key.startsWith(`${PLUGIN_NAME}@`);
|
|
76
|
+
/** Whether the buildit plugin is installed or enabled (by the files Claude Code keeps). */
|
|
77
|
+
export function pluginConfigured(folder, home = homedir()) {
|
|
78
|
+
const installed = readJson(join(home, '.claude', 'plugins', 'installed_plugins.json'));
|
|
79
|
+
if (Object.keys(recordOf(installed?.plugins)).some(isPluginKey))
|
|
80
|
+
return true;
|
|
81
|
+
for (const path of [
|
|
82
|
+
join(home, '.claude', 'settings.json'),
|
|
83
|
+
join(folder, '.claude', 'settings.json'),
|
|
84
|
+
join(folder, '.claude', 'settings.local.json'),
|
|
85
|
+
]) {
|
|
86
|
+
const enabled = recordOf(readJson(path)?.enabledPlugins);
|
|
87
|
+
if (Object.entries(enabled).some(([key, on]) => isPluginKey(key) && on === true))
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/** The warning `status` prints when both are set up, or undefined. */
|
|
93
|
+
export function duplicateChannelWarning(folder, home = homedir()) {
|
|
94
|
+
const servers = channelServers(folder, home);
|
|
95
|
+
if (servers.length === 0 || !pluginConfigured(folder, home))
|
|
96
|
+
return undefined;
|
|
97
|
+
const places = [...new Set(servers.map((s) => s.place))];
|
|
98
|
+
const names = servers.map((s) => `"${s.name}"`).join(', ');
|
|
99
|
+
return `Warning: the buildit plugin is installed, and ${places.join(' and ')} also starts "buildit-mcp --channel" for this folder (${names}). Only one of them can be the channel, and the plugin's wins: remove the other server (claude mcp remove ${servers[0]?.name ?? '<name>'}), then restart Claude Code.`;
|
|
100
|
+
}
|