@kammcs/buildit-mcp 0.1.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +299 -72
  3. package/SECURITY.md +5 -5
  4. package/dist/api/client.js +5 -1
  5. package/dist/api/generated/operations.js +717 -4
  6. package/dist/api/generated/schemas.js +718 -3
  7. package/dist/api/generated/strict.js +859 -3
  8. package/dist/channel/api.js +128 -0
  9. package/dist/channel/approvals.js +259 -0
  10. package/dist/channel/item-events.js +138 -0
  11. package/dist/channel/lock.js +181 -0
  12. package/dist/channel/loop.js +776 -0
  13. package/dist/channel/mask.js +365 -0
  14. package/dist/channel/threads.js +60 -0
  15. package/dist/channel/tool-ring.js +124 -0
  16. package/dist/channel/typing.js +150 -0
  17. package/dist/channel/waiters.js +229 -0
  18. package/dist/channel/workers.js +66 -0
  19. package/dist/cli/claude-config.js +100 -0
  20. package/dist/cli/commands.js +410 -0
  21. package/dist/config.js +329 -6
  22. package/dist/errors.js +38 -0
  23. package/dist/index.js +122 -6
  24. package/dist/log.js +3 -2
  25. package/dist/secrets/keychain.js +273 -0
  26. package/dist/server.js +53 -5
  27. package/dist/tools/approve.js +791 -0
  28. package/dist/tools/ask.js +898 -0
  29. package/dist/tools/chat.js +98 -1
  30. package/dist/tools/items-read.js +41 -5
  31. package/dist/tools/items-write.js +66 -15
  32. package/dist/tools/pages.js +81 -1
  33. package/dist/tools/plans.js +221 -14
  34. package/dist/tools/projects.js +38 -24
  35. package/dist/tools/reply.js +138 -0
  36. package/dist/tools/shared.js +34 -1
  37. package/dist/tools/threads.js +123 -0
  38. package/dist/tools/whoami.js +29 -4
  39. package/dist/toolsets/catalog.js +5 -2
  40. package/dist/toolsets/registry.js +2 -0
  41. package/dist/transports/stdio.js +260 -6
  42. package/package.json +5 -1
@@ -0,0 +1,181 @@
1
+ /**
2
+ * One channel server per folder.
3
+ *
4
+ * Claude Code may start more than one buildit server for the same folder
5
+ * (the plugin's and one added with `claude mcp add`, or two Claude Code
6
+ * windows). Only one of them may read the session's events, or each would
7
+ * see only some of the messages and answers. The server that reads them
8
+ * holds a lock file keyed like the folder's saved session (the SHA-256 of
9
+ * the folder and the API's origin), next to the token file, created
10
+ * exclusively (folder 0700, file 0600), holding `{"pid": n, "started_at":
11
+ * "…Z", "plugin": bool}`. It is held for the process's life and removed
12
+ * when the process exits. A lock whose process is gone (a crash) is taken
13
+ * over.
14
+ *
15
+ * A server started by the buildit plugin (`--from-plugin`, which the
16
+ * plugin's manifest passes) comes first, since Claude Code shows channel
17
+ * messages only from a server it registered as a channel, which the plugin
18
+ * is: plugin servers take `<key>.plugin.lock`, the others `<key>.lock`.
19
+ * The first plugin server wins over later plugin servers. A server without
20
+ * the plugin is the channel only while no plugin server holds its lock: it
21
+ * checks before each poll and before handing on what it fetched
22
+ * (`ChannelLock.owns`), and once a plugin server holds it, it releases its
23
+ * own lock and stops polling for good. The two files make this race-free:
24
+ * each is created exclusively, and only the non-plugin server ever gives
25
+ * way. A plugin server that found a non-plugin server holding the folder
26
+ * waits a moment before its first poll (`PREEMPT_WAIT_MS`), so a poll the
27
+ * other server had already started ends first.
28
+ *
29
+ * The others serve their tools without the channel: no loop, and `reply`,
30
+ * `ask` and `whoami` say another server receives the messages.
31
+ */
32
+ import { mkdirSync, openSync, closeSync, readFileSync, rmSync, writeSync, chmodSync, } from 'node:fs';
33
+ import { dirname, join } from 'node:path';
34
+ import { defaultTokenFile, sessionKey } from '../secrets/keychain.js';
35
+ /** How long a plugin server that took the folder over waits before its first poll. */
36
+ export const PREEMPT_WAIT_MS = 3000;
37
+ /** The text `reply` and `ask` give in a server that isn't the folder's channel. */
38
+ export const NOT_THE_CHANNEL = 'Another buildIt server in this folder receives the messages from buildIt.Social (it is the channel), so this one can\'t answer there. Remove the extra one with `claude mcp remove <name>` ("buildit-mcp status" in the folder names them), then restart Claude Code.';
39
+ /** The default folder for lock files. */
40
+ export function defaultLockDir() {
41
+ return join(dirname(defaultTokenFile()), 'locks');
42
+ }
43
+ /** Whether the process with this id is running (EPERM: it is, under another user). */
44
+ export function processAlive(pid) {
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ }
49
+ catch (err) {
50
+ return err.code === 'EPERM';
51
+ }
52
+ }
53
+ function readHolder(path) {
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
56
+ if (typeof parsed.pid !== 'number' || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
57
+ return undefined;
58
+ }
59
+ return {
60
+ pid: parsed.pid,
61
+ started_at: typeof parsed.started_at === 'string' ? parsed.started_at : undefined,
62
+ plugin: parsed.plugin === true,
63
+ };
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ /** The two lock files of a folder: the plugin servers', and the others'. */
70
+ export function lockPaths(folder, apiUrl, dir = defaultLockDir()) {
71
+ const key = sessionKey(folder, apiUrl);
72
+ return { plugin: join(dir, `${key}.plugin.lock`), server: join(dir, `${key}.lock`) };
73
+ }
74
+ /** The live holders of a folder's locks (for status), by kind. */
75
+ export function lockHolders(folder, apiUrl, options = {}) {
76
+ const isAlive = options.isAlive ?? processAlive;
77
+ const paths = lockPaths(folder, apiUrl, options.dir);
78
+ const live = (path) => {
79
+ const h = readHolder(path);
80
+ return h !== undefined && isAlive(h.pid) ? h : undefined;
81
+ };
82
+ return { plugin: live(paths.plugin), server: live(paths.server) };
83
+ }
84
+ /**
85
+ * Creates a lock file exclusively, or returns undefined when another
86
+ * running process holds it. A lock left by a process that is gone, or
87
+ * one that can't be read, is taken over (once: two servers racing for it
88
+ * can't both win, since the file is created exclusively).
89
+ */
90
+ function take(path, pid, plugin, isAlive, now) {
91
+ for (let attempt = 0; attempt < 2; attempt++) {
92
+ let fd;
93
+ try {
94
+ fd = openSync(path, 'wx', 0o600);
95
+ }
96
+ catch (err) {
97
+ if (err.code !== 'EEXIST')
98
+ throw err;
99
+ const holder = readHolder(path)?.pid;
100
+ if (holder !== undefined && holder !== pid && isAlive(holder))
101
+ return false;
102
+ // The holder is gone (or the file is unreadable, or it is this process): take it over.
103
+ rmSync(path, { force: true });
104
+ continue;
105
+ }
106
+ try {
107
+ writeSync(fd, JSON.stringify({ pid, started_at: now().toISOString(), plugin }) + '\n');
108
+ }
109
+ finally {
110
+ closeSync(fd);
111
+ }
112
+ try {
113
+ chmodSync(path, 0o600);
114
+ }
115
+ catch {
116
+ // Best effort (Windows has no mode bits).
117
+ }
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ /**
123
+ * Takes the folder's channel lock, or returns undefined when another
124
+ * server is the channel: a running plugin server (for any server), or a
125
+ * running server without the plugin (for another one without it).
126
+ */
127
+ export function acquireChannelLock(folder, apiUrl, options = {}) {
128
+ const dir = options.dir ?? defaultLockDir();
129
+ const paths = lockPaths(folder, apiUrl, dir);
130
+ const plugin = options.plugin === true;
131
+ const pid = options.pid ?? process.pid;
132
+ const isAlive = options.isAlive ?? processAlive;
133
+ const now = options.now ?? (() => new Date());
134
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
135
+ const pluginHolder = () => {
136
+ const h = readHolder(paths.plugin)?.pid;
137
+ return h !== undefined && h !== pid && isAlive(h) ? h : undefined;
138
+ };
139
+ let preempted;
140
+ if (plugin) {
141
+ if (!take(paths.plugin, pid, true, isAlive, now))
142
+ return undefined;
143
+ const other = readHolder(paths.server)?.pid;
144
+ if (other !== undefined && other !== pid && isAlive(other))
145
+ preempted = other;
146
+ }
147
+ else {
148
+ if (pluginHolder() !== undefined)
149
+ return undefined;
150
+ if (!take(paths.server, pid, false, isAlive, now))
151
+ return undefined;
152
+ }
153
+ const path = plugin ? paths.plugin : paths.server;
154
+ let released = false;
155
+ const release = () => {
156
+ if (released)
157
+ return;
158
+ released = true;
159
+ process.removeListener('exit', release);
160
+ // Only our own lock: a later holder's file stays.
161
+ if (readHolder(path)?.pid === pid)
162
+ rmSync(path, { force: true });
163
+ };
164
+ if (options.releaseOnExit !== false)
165
+ process.once('exit', release);
166
+ return {
167
+ path,
168
+ plugin,
169
+ preempted,
170
+ owns() {
171
+ if (released || readHolder(path)?.pid !== pid)
172
+ return false;
173
+ if (!plugin && pluginHolder() !== undefined) {
174
+ release();
175
+ return false;
176
+ }
177
+ return true;
178
+ },
179
+ release,
180
+ };
181
+ }