@kammcs/buildit-mcp 0.2.0 → 0.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +273 -32
  3. package/SECURITY.md +1 -1
  4. package/dist/api/client.js +5 -1
  5. package/dist/api/generated/operations.js +840 -11
  6. package/dist/api/generated/schemas.js +936 -3
  7. package/dist/api/generated/strict.js +1088 -3
  8. package/dist/capabilities.js +236 -0
  9. package/dist/channel/api.js +159 -0
  10. package/dist/channel/approvals.js +259 -0
  11. package/dist/channel/handoffs.js +190 -0
  12. package/dist/channel/item-events.js +164 -0
  13. package/dist/channel/lock.js +181 -0
  14. package/dist/channel/loop.js +1034 -0
  15. package/dist/channel/mask.js +365 -0
  16. package/dist/channel/threads.js +60 -0
  17. package/dist/channel/tool-ring.js +124 -0
  18. package/dist/channel/typing.js +150 -0
  19. package/dist/channel/waiters.js +229 -0
  20. package/dist/channel/workers.js +66 -0
  21. package/dist/cli/claude-config.js +100 -0
  22. package/dist/cli/commands.js +438 -0
  23. package/dist/config.js +356 -6
  24. package/dist/errors.js +49 -1
  25. package/dist/index.js +122 -6
  26. package/dist/log.js +3 -2
  27. package/dist/secrets/keychain.js +273 -0
  28. package/dist/server.js +61 -5
  29. package/dist/tools/approve.js +791 -0
  30. package/dist/tools/ask.js +898 -0
  31. package/dist/tools/chat.js +98 -1
  32. package/dist/tools/handoff.js +409 -0
  33. package/dist/tools/items-read.js +6 -3
  34. package/dist/tools/items-write.js +77 -16
  35. package/dist/tools/plans.js +221 -14
  36. package/dist/tools/reply.js +138 -0
  37. package/dist/tools/shared.js +24 -1
  38. package/dist/tools/threads.js +149 -0
  39. package/dist/tools/whoami.js +53 -3
  40. package/dist/toolsets/catalog.js +3 -1
  41. package/dist/toolsets/registry.js +2 -0
  42. package/dist/transports/stdio.js +288 -12
  43. package/package.json +5 -1
package/dist/config.js CHANGED
@@ -5,25 +5,46 @@
5
5
  * environment (a flag would be visible to other processes), and no message
6
6
  * produced here ever contains it.
7
7
  */
8
+ import { basename, resolve } from 'node:path';
8
9
  import { parseArgs } from 'node:util';
10
+ import { parseCapabilitiesOption } from './capabilities.js';
9
11
  import { LOG_LEVELS, scrubString } from './log.js';
10
12
  import { DEFAULT_TOOLSETS, isToolsetName, TOOLSET_NAMES, } from './toolsets/toolsets.js';
11
13
  export const DEFAULT_API_URL = 'https://api.buildit.social/functions/v1/agent-api';
12
14
  export const DEFAULT_HTTP_HOST = '127.0.0.1';
13
15
  export const DEFAULT_HTTP_PORT = 8765;
16
+ export const DEFAULT_ASK_TIMEOUT_MINUTES = 60;
17
+ export const DEFAULT_APPROVAL_TIMEOUT_MINUTES = 60;
18
+ export const DEFAULT_MAX_PARALLEL_ITEMS = 3;
14
19
  export class ConfigError extends Error {
15
20
  name = 'ConfigError';
16
21
  }
17
22
  export const USAGE = `Usage: buildit-mcp [options]
23
+ buildit-mcp connect --project <KEY> [--label <name>] [--folder <path>] [--api-url <url>]
24
+ buildit-mcp disconnect [--folder <path>] [--api-url <url>]
25
+ buildit-mcp status [--folder <path>] [--api-url <url>]
18
26
 
19
27
  An MCP server for buildIt.Social. By default it serves one person over stdio,
20
28
  with the personal access token in the BUILDIT_TOKEN environment variable.
29
+ With --channel it is also a Claude Code channel: the messages its owner sends
30
+ to the agent in buildIt.Social arrive in the Claude Code session, which
31
+ answers with the reply tool. Its token is the session token that
32
+ "buildit-mcp connect" saved for the folder (or BUILDIT_TOKEN).
33
+
34
+ Commands (run them in a terminal, in the folder where Claude Code runs):
35
+ connect Connect this folder to buildIt.Social as an agent
36
+ session: shows a code to approve in the app, then
37
+ saves the session token in the OS keychain
38
+ disconnect Forget this folder's saved session
39
+ status Show this folder's session
40
+ Run "buildit-mcp <command> --help" for a command's options.
21
41
 
22
42
  Options (each also has an environment variable):
23
43
  --api-url <url> BUILDIT_API_URL Agent API base URL
24
44
  (default ${DEFAULT_API_URL})
25
45
  --toolsets <list> BUILDIT_TOOLSETS Comma-separated toolsets, or "all"
26
- (default ${DEFAULT_TOOLSETS.join(',')};
46
+ (default ${DEFAULT_TOOLSETS.join(',')}, or all
47
+ with --channel;
27
48
  known: ${TOOLSET_NAMES.join(', ')})
28
49
  --read-only BUILDIT_READ_ONLY=true Only list tools that change nothing
29
50
  --exclude-tools <list> BUILDIT_EXCLUDE_TOOLS Comma-separated tool names to hide
@@ -33,6 +54,29 @@ Options (each also has an environment variable):
33
54
  --port <port> BUILDIT_PORT HTTP port (default ${DEFAULT_HTTP_PORT})
34
55
  --allowed-hosts <list> BUILDIT_ALLOWED_HOSTS Hostnames accepted in the Host header
35
56
  --allowed-origins <list> BUILDIT_ALLOWED_ORIGINS Hostnames accepted in the Origin header
57
+ --channel BUILDIT_CHANNEL=true Claude Code channel mode (stdio only)
58
+ --folder <path> BUILDIT_FOLDER The folder whose saved session --channel uses
59
+ (default: the working directory)
60
+ --from-plugin --channel: started by the buildit plugin, which
61
+ Claude Code registers as the channel (the
62
+ plugin passes it)
63
+ BUILDIT_QUESTIONS_IN_APP --channel: the plugin's question hook asks in
64
+ buildIt.Social (default true)
65
+ BUILDIT_ASK_TIMEOUT_MINUTES
66
+ --channel: how long a question card waits,
67
+ 1 to 60 (default 60)
68
+ BUILDIT_MAX_PARALLEL_ITEMS
69
+ --channel with the plugin: how many work items
70
+ are worked on at once, 1 to 10 (default 3)
71
+ BUILDIT_APPROVALS_IN_APP --channel: the plugin's permission hook asks to
72
+ approve tool calls in buildIt.Social too
73
+ (default true)
74
+ BUILDIT_APPROVAL_TIMEOUT_MINUTES
75
+ --channel: how long a tool approval card waits,
76
+ 1 to 60 (default 60)
77
+ BUILDIT_CAPABILITIES --channel: the capability tags the session
78
+ suggests when it starts (macos,ios, or none),
79
+ instead of detecting them
36
80
  --allow-any-host BUILDIT_ALLOW_ANY_HOST=true
37
81
  Off loopback, serve without checking the
38
82
  Host header (otherwise BUILDIT_ALLOWED_HOSTS
@@ -43,6 +87,60 @@ Options (each also has an environment variable):
43
87
  The token is never accepted as a flag. In stdio mode set BUILDIT_TOKEN; in HTTP
44
88
  mode every request carries its own "Authorization: Bearer <token>" header.
45
89
  `;
90
+ export const COMMAND_USAGE = {
91
+ connect: `Usage: buildit-mcp connect --project <KEY> [options]
92
+
93
+ Connects this folder to buildIt.Social as a session of your personal agent,
94
+ for Claude Code's channel mode (buildit-mcp --channel). It shows a code and a
95
+ link: open the link, check the code, and approve in buildIt.Social. The
96
+ session token is then saved in the OS keychain for this folder and API.
97
+
98
+ Options:
99
+ --project <KEY> A project the session may reach (repeat it, or
100
+ separate keys with commas; 1 to 10)
101
+ --label <name> The session's name in buildIt.Social, 1 to 40
102
+ characters (default: the folder's name)
103
+ --folder <path> The folder (default: the working directory)
104
+ --api-url <url> BUILDIT_API_URL Agent API base URL
105
+ (default ${DEFAULT_API_URL})
106
+ --capabilities <tags> The capability tags to suggest (macos,ios), or
107
+ none, instead of detecting them. Detection looks
108
+ at the operating system and for known programs
109
+ (flutter, docker, cargo, adb, Xcode) without
110
+ running them, and sends only the tag names
111
+ --yes Save without asking "Is this you?" once it is
112
+ approved. For scripts only: unsafe if anyone else
113
+ could have seen the code, since whoever approves
114
+ it gets the session
115
+ -h, --help Show this help
116
+
117
+ Once approved, it says who approved it (the agent, its owner, the org, the
118
+ projects) and asks "Is this you?"; only "y" saves the token, and any other
119
+ answer cancels the session.
120
+ `,
121
+ disconnect: `Usage: buildit-mcp disconnect [options]
122
+
123
+ Cancels this folder's session in buildIt.Social and forgets its saved token.
124
+ If buildIt.Social can't be reached, the token is forgotten anyway: revoke
125
+ the session in the app then.
126
+
127
+ Options:
128
+ --folder <path> The folder (default: the working directory)
129
+ --api-url <url> BUILDIT_API_URL Agent API base URL
130
+ -h, --help Show this help
131
+ `,
132
+ status: `Usage: buildit-mcp status [options]
133
+
134
+ Shows the session saved for this folder: its agent, name, org and reach, and
135
+ whether buildIt.Social still accepts it.
136
+
137
+ Options:
138
+ --folder <path> The folder (default: the working directory)
139
+ --api-url <url> BUILDIT_API_URL Agent API base URL
140
+ -h, --help Show this help
141
+ `,
142
+ };
143
+ const SESSION_COMMANDS = ['connect', 'disconnect', 'status'];
46
144
  const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
47
145
  const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
48
146
  export function parseBoolean(name, raw, fallback) {
@@ -55,6 +153,73 @@ export function parseBoolean(name, raw, fallback) {
55
153
  return false;
56
154
  throw new ConfigError(`${name} must be true or false (got "${raw}").`);
57
155
  }
156
+ /**
157
+ * The plugin's settings arrive as environment variables it fills in, so a
158
+ * value that is empty or doesn't parse keeps the default, with a warning,
159
+ * instead of stopping the server.
160
+ */
161
+ function parseAsk(env, warnings) {
162
+ let questionsInApp = true;
163
+ const rawIn = env.BUILDIT_QUESTIONS_IN_APP?.trim() ?? '';
164
+ if (rawIn !== '') {
165
+ const value = rawIn.toLowerCase();
166
+ if (TRUE_VALUES.has(value))
167
+ questionsInApp = true;
168
+ else if (FALSE_VALUES.has(value))
169
+ questionsInApp = false;
170
+ else
171
+ warnings.push('BUILDIT_QUESTIONS_IN_APP is not true or false; questions go to buildIt.Social.');
172
+ }
173
+ let timeoutMinutes = DEFAULT_ASK_TIMEOUT_MINUTES;
174
+ const rawMinutes = env.BUILDIT_ASK_TIMEOUT_MINUTES?.trim() ?? '';
175
+ if (rawMinutes !== '') {
176
+ const n = Number(rawMinutes);
177
+ if (Number.isFinite(n) && n >= 1 && n <= 60) {
178
+ timeoutMinutes = Math.floor(n);
179
+ }
180
+ else {
181
+ warnings.push(`BUILDIT_ASK_TIMEOUT_MINUTES must be 1 to 60; using ${DEFAULT_ASK_TIMEOUT_MINUTES}.`);
182
+ }
183
+ }
184
+ let maxParallelItems = DEFAULT_MAX_PARALLEL_ITEMS;
185
+ const rawParallel = env.BUILDIT_MAX_PARALLEL_ITEMS?.trim() ?? '';
186
+ if (rawParallel !== '') {
187
+ const n = Number(rawParallel);
188
+ if (Number.isFinite(n) && n >= 1 && n <= 10) {
189
+ maxParallelItems = Math.floor(n);
190
+ }
191
+ else {
192
+ warnings.push(`BUILDIT_MAX_PARALLEL_ITEMS must be 1 to 10; using ${DEFAULT_MAX_PARALLEL_ITEMS}.`);
193
+ }
194
+ }
195
+ return { questionsInApp, timeoutMinutes, maxParallelItems };
196
+ }
197
+ /** The tool approval settings, read as the question settings are (a bad value keeps the default). */
198
+ function parseApprovals(env, warnings) {
199
+ let inApp = true;
200
+ const rawIn = env.BUILDIT_APPROVALS_IN_APP?.trim() ?? '';
201
+ if (rawIn !== '') {
202
+ const value = rawIn.toLowerCase();
203
+ if (TRUE_VALUES.has(value))
204
+ inApp = true;
205
+ else if (FALSE_VALUES.has(value))
206
+ inApp = false;
207
+ else
208
+ warnings.push('BUILDIT_APPROVALS_IN_APP is not true or false; tool approvals go to buildIt.Social.');
209
+ }
210
+ let timeoutMinutes = DEFAULT_APPROVAL_TIMEOUT_MINUTES;
211
+ const rawMinutes = env.BUILDIT_APPROVAL_TIMEOUT_MINUTES?.trim() ?? '';
212
+ if (rawMinutes !== '') {
213
+ const n = Number(rawMinutes);
214
+ if (Number.isFinite(n) && n >= 1 && n <= 60) {
215
+ timeoutMinutes = Math.floor(n);
216
+ }
217
+ else {
218
+ warnings.push(`BUILDIT_APPROVAL_TIMEOUT_MINUTES must be 1 to 60; using ${DEFAULT_APPROVAL_TIMEOUT_MINUTES}.`);
219
+ }
220
+ }
221
+ return { inApp, timeoutMinutes };
222
+ }
58
223
  function splitList(raw) {
59
224
  return [
60
225
  ...new Set(raw
@@ -90,6 +255,24 @@ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
90
255
  export function isLoopbackHost(host) {
91
256
  return LOOPBACK_HOSTS.has(host.toLowerCase());
92
257
  }
258
+ /** The agent API's path on a buildIt.Social host. */
259
+ export const AGENT_API_PATH = '/functions/v1/agent-api';
260
+ /**
261
+ * The note shown once when an API address is a bare host (no path), which
262
+ * parseApiUrl completes with the agent API's path; undefined otherwise.
263
+ */
264
+ export function apiUrlNote(raw) {
265
+ if (raw === undefined)
266
+ return undefined;
267
+ try {
268
+ if (new URL(raw.trim()).pathname !== '/')
269
+ return undefined;
270
+ }
271
+ catch {
272
+ return undefined;
273
+ }
274
+ return `The API address ${raw.trim()} has no path, so buildit-mcp uses ${parseApiUrl(raw)}, the agent API on that host.`;
275
+ }
93
276
  export function parseApiUrl(raw) {
94
277
  let url;
95
278
  try {
@@ -108,6 +291,9 @@ export function parseApiUrl(raw) {
108
291
  if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
109
292
  throw new ConfigError('BUILDIT_API_URL must use https:// (plain http:// is only allowed for localhost).');
110
293
  }
294
+ // A bare host means its agent API.
295
+ if (url.pathname === '/')
296
+ url.pathname = AGENT_API_PATH;
111
297
  return url.toString().replace(/\/+$/, '');
112
298
  }
113
299
  const HOSTNAME = /^(\[[0-9a-f:.]+\]|[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$/;
@@ -166,15 +352,134 @@ function parseLogLevel(raw) {
166
352
  function nonEmpty(value) {
167
353
  return value === undefined || value.trim() === '' ? undefined : value;
168
354
  }
355
+ const PROJECT_KEY = /^[A-Za-z][A-Za-z0-9]{1,5}$/;
356
+ export const LABEL_MAX_CHARS = 40;
357
+ /** Control, direction and zero-width characters (a RegExp character class), which a session label may not hold. */
358
+ export const FORBIDDEN_CHARS = '[\\u0000-\\u001F\\u007F-\\u009F\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2069\\uFEFF]';
359
+ /** Project keys from --project flags (each may list several, comma-separated). */
360
+ export function parseProjects(values) {
361
+ const keys = [...new Set(values.flatMap((v) => splitList(v)).map((k) => k.toUpperCase()))];
362
+ if (keys.length === 0) {
363
+ throw new ConfigError('Name the project the session may reach with --project <KEY>, for example --project DEMO.');
364
+ }
365
+ const bad = keys.filter((k) => !PROJECT_KEY.test(k));
366
+ if (bad.length > 0) {
367
+ throw new ConfigError(`Not a project key: ${bad.join(', ')}. Keys are 2 to 6 letters and digits, starting with a letter (for example DEMO).`);
368
+ }
369
+ if (keys.length > 10)
370
+ throw new ConfigError('A session may reach at most 10 projects.');
371
+ return keys;
372
+ }
373
+ /** --capabilities or BUILDIT_CAPABILITIES, checked (ConfigError names what is wrong). */
374
+ function capabilitiesOption(raw, what) {
375
+ try {
376
+ return parseCapabilitiesOption(raw);
377
+ }
378
+ catch (err) {
379
+ throw new ConfigError(`${what}: ${err instanceof Error ? err.message : String(err)}`);
380
+ }
381
+ }
382
+ /** The --label given, checked; or the folder's name, made to fit. */
383
+ export function sessionLabel(raw, folder) {
384
+ if (raw !== undefined) {
385
+ const label = raw.trim();
386
+ if (label.length === 0 || Array.from(label).length > LABEL_MAX_CHARS) {
387
+ throw new ConfigError(`The label must be 1 to ${LABEL_MAX_CHARS} characters.`);
388
+ }
389
+ if (new RegExp(FORBIDDEN_CHARS, 'u').test(label)) {
390
+ throw new ConfigError('The label may not hold control, direction or invisible characters.');
391
+ }
392
+ return label;
393
+ }
394
+ const name = Array.from(basename(folder).replace(new RegExp(FORBIDDEN_CHARS, 'gu'), '').trim())
395
+ .slice(0, LABEL_MAX_CHARS)
396
+ .join('')
397
+ .trim();
398
+ return name.length > 0 ? name : 'session';
399
+ }
400
+ /** The folder a session belongs to: --folder, else BUILDIT_FOLDER, else the working directory. */
401
+ function folderOf(flag, env, cwd) {
402
+ const raw = typeof flag === 'string' ? flag : nonEmpty(env.BUILDIT_FOLDER);
403
+ if (raw !== undefined && raw.trim() === '')
404
+ throw new ConfigError('--folder must not be empty.');
405
+ return resolve(cwd, raw?.trim() ?? '.');
406
+ }
407
+ function parseSessionCommand(command, argv, env, cwd, secrets) {
408
+ let values;
409
+ try {
410
+ ({ values } = parseArgs({
411
+ args: [...argv],
412
+ strict: true,
413
+ allowPositionals: false,
414
+ options: {
415
+ 'api-url': { type: 'string' },
416
+ folder: { type: 'string' },
417
+ help: { type: 'boolean', short: 'h' },
418
+ ...(command === 'connect'
419
+ ? {
420
+ project: { type: 'string', multiple: true },
421
+ label: { type: 'string' },
422
+ capabilities: { type: 'string' },
423
+ yes: { type: 'boolean' },
424
+ }
425
+ : {}),
426
+ },
427
+ }));
428
+ }
429
+ catch (err) {
430
+ const message = err instanceof Error ? err.message : String(err);
431
+ throw new ConfigError(`${scrubString(message, secrets)} Run "buildit-mcp ${command} --help" for the options.`);
432
+ }
433
+ if (values.help === true)
434
+ return { kind: 'help', command };
435
+ const apiFlag = values['api-url'];
436
+ const apiRaw = (typeof apiFlag === 'string' ? apiFlag : nonEmpty(env.BUILDIT_API_URL)) ?? DEFAULT_API_URL;
437
+ const apiUrl = parseApiUrl(apiRaw);
438
+ const note = apiUrlNote(apiRaw);
439
+ const folder = folderOf(values.folder, env, cwd);
440
+ if (command !== 'connect') {
441
+ return {
442
+ kind: command,
443
+ options: {
444
+ apiUrl,
445
+ folder,
446
+ apiUrlFlag: typeof apiFlag === 'string',
447
+ ...(note === undefined ? {} : { apiUrlNote: note }),
448
+ },
449
+ };
450
+ }
451
+ const projectFlags = values.project;
452
+ const projects = parseProjects(Array.isArray(projectFlags) ? projectFlags : []);
453
+ const label = sessionLabel(typeof values.label === 'string' ? values.label : undefined, folder);
454
+ const capabilities = typeof values.capabilities === 'string'
455
+ ? capabilitiesOption(values.capabilities, '--capabilities')
456
+ : undefined;
457
+ return {
458
+ kind: 'connect',
459
+ options: {
460
+ apiUrl,
461
+ folder,
462
+ projects,
463
+ label,
464
+ yes: values.yes === true,
465
+ ...(capabilities === undefined ? {} : { capabilities }),
466
+ ...(note === undefined ? {} : { apiUrlNote: note }),
467
+ },
468
+ };
469
+ }
169
470
  /**
170
471
  * Parses flags and environment variables. Throws ConfigError with a message
171
472
  * that is safe to print (it never contains the token).
172
473
  */
173
- export function parseCli(argv, env) {
474
+ export function parseCli(argv, env, cwd = process.cwd()) {
174
475
  const secrets = env.BUILDIT_TOKEN ? [env.BUILDIT_TOKEN] : [];
175
476
  if (argv.some((a) => a === '--token' || a.startsWith('--token='))) {
176
477
  throw new ConfigError('The token is never accepted as a flag (other processes can read flags). Set the BUILDIT_TOKEN environment variable instead.');
177
478
  }
479
+ const command = SESSION_COMMANDS.find((c) => c === argv[0]);
480
+ if (command !== undefined) {
481
+ return parseSessionCommand(command, argv.slice(1), env, cwd, secrets);
482
+ }
178
483
  let values;
179
484
  try {
180
485
  ({ values } = parseArgs({
@@ -193,6 +498,9 @@ export function parseCli(argv, env) {
193
498
  'allowed-hosts': { type: 'string' },
194
499
  'allowed-origins': { type: 'string' },
195
500
  'allow-any-host': { type: 'boolean' },
501
+ channel: { type: 'boolean' },
502
+ folder: { type: 'string' },
503
+ 'from-plugin': { type: 'boolean' },
196
504
  help: { type: 'boolean', short: 'h' },
197
505
  version: { type: 'boolean', short: 'v' },
198
506
  },
@@ -213,9 +521,23 @@ export function parseCli(argv, env) {
213
521
  const warnings = [];
214
522
  const http = values.http === true || parseBoolean('BUILDIT_HTTP', env.BUILDIT_HTTP, false);
215
523
  const mode = http ? 'http' : 'stdio';
216
- const apiUrl = parseApiUrl(str('api-url', 'BUILDIT_API_URL') ?? DEFAULT_API_URL);
524
+ const channelMode = values.channel === true || parseBoolean('BUILDIT_CHANNEL', env.BUILDIT_CHANNEL, false);
525
+ if (channelMode && mode === 'http') {
526
+ throw new ConfigError('Channel mode (--channel) runs over stdio only: Claude Code starts it as a local server.');
527
+ }
528
+ const apiRaw = str('api-url', 'BUILDIT_API_URL') ?? DEFAULT_API_URL;
529
+ const apiUrl = parseApiUrl(apiRaw);
530
+ const apiNote = apiUrlNote(apiRaw);
531
+ if (apiNote !== undefined)
532
+ warnings.push(apiNote);
217
533
  const toolsetsRaw = str('toolsets', 'BUILDIT_TOOLSETS');
218
- const toolsets = toolsetsRaw === undefined ? [...DEFAULT_TOOLSETS] : parseToolsets(toolsetsRaw);
534
+ // Channel mode turns every toolset on by default: approvals need destructive and admin (the
535
+ // session's own scopes still limit what works).
536
+ const toolsets = toolsetsRaw === undefined
537
+ ? channelMode
538
+ ? [...TOOLSET_NAMES]
539
+ : [...DEFAULT_TOOLSETS]
540
+ : parseToolsets(toolsetsRaw);
219
541
  const readOnly = values['read-only'] === true || parseBoolean('BUILDIT_READ_ONLY', env.BUILDIT_READ_ONLY, false);
220
542
  const excludeRaw = str('exclude-tools', 'BUILDIT_EXCLUDE_TOOLS');
221
543
  const excludeTools = excludeRaw === undefined ? [] : parseToolNames(excludeRaw, 'BUILDIT_EXCLUDE_TOOLS');
@@ -236,8 +558,33 @@ export function parseCli(argv, env) {
236
558
  httpConfig.allowedOrigins = parseHostList(originsRaw, 'BUILDIT_ALLOWED_ORIGINS', true);
237
559
  }
238
560
  let token;
561
+ let channel;
239
562
  const rawToken = env.BUILDIT_TOKEN;
240
- if (mode === 'stdio') {
563
+ if (channelMode) {
564
+ channel = {
565
+ folder: folderOf(values.folder, env, cwd),
566
+ tokenSource: 'store',
567
+ apiUrlFlag: typeof values['api-url'] === 'string',
568
+ fromPlugin: values['from-plugin'] === true || nonEmpty(env.CLAUDE_PLUGIN_ROOT) !== undefined,
569
+ };
570
+ const rawCapabilities = nonEmpty(env.BUILDIT_CAPABILITIES);
571
+ if (rawCapabilities !== undefined) {
572
+ channel.capabilities = capabilitiesOption(rawCapabilities, 'BUILDIT_CAPABILITIES');
573
+ }
574
+ const envToken = rawToken?.trim() ?? '';
575
+ if (envToken.startsWith('buildit_ags_')) {
576
+ if (/\s/.test(envToken)) {
577
+ throw new ConfigError('BUILDIT_TOKEN contains whitespace; check that it was copied whole.');
578
+ }
579
+ token = envToken;
580
+ channel.tokenSource = 'env';
581
+ }
582
+ else if (envToken !== '') {
583
+ // A user-level personal access token from the usual setup: the folder's session wins.
584
+ warnings.push('BUILDIT_TOKEN is not a session token, so channel mode ignores it and uses the session that "buildit-mcp connect" saved for the folder.');
585
+ }
586
+ }
587
+ else if (mode === 'stdio') {
241
588
  if (rawToken === undefined || rawToken.trim() === '') {
242
589
  throw new ConfigError('BUILDIT_TOKEN is not set. Create a personal access token in buildIt.Social and put it in the BUILDIT_TOKEN environment variable (never in a file you commit).');
243
590
  }
@@ -245,7 +592,7 @@ export function parseCli(argv, env) {
245
592
  if (/\s/.test(token)) {
246
593
  throw new ConfigError('BUILDIT_TOKEN contains whitespace; check that it was copied whole.');
247
594
  }
248
- if (!token.startsWith('buildit_pat_')) {
595
+ if (!token.startsWith('buildit_pat_') && !token.startsWith('buildit_ags_')) {
249
596
  warnings.push('BUILDIT_TOKEN does not start with "buildit_pat_"; buildIt.Social personal access tokens do.');
250
597
  }
251
598
  }
@@ -269,6 +616,9 @@ export function parseCli(argv, env) {
269
616
  mode,
270
617
  apiUrl,
271
618
  ...(token === undefined ? {} : { token }),
619
+ ...(channel === undefined ? {} : { channel }),
620
+ ask: parseAsk(env, warnings),
621
+ approvals: parseApprovals(env, warnings),
272
622
  toolsets,
273
623
  readOnly,
274
624
  excludeTools,
package/dist/errors.js CHANGED
@@ -73,6 +73,15 @@ const NOT_FOUND_HINTS = {
73
73
  plan: 'Use a handle a propose_* tool returned in this conversation, or propose the change again.',
74
74
  route: 'The API does not serve this call: it may be older than this buildit-mcp. Check BUILDIT_API_URL, or use another tool.',
75
75
  };
76
+ /**
77
+ * The next step for codes whose contract hint names an API operation this
78
+ * server has no tool for (get_session_handoff): said in this server's terms
79
+ * instead of the API's hint.
80
+ */
81
+ const SERVER_HINTS = {
82
+ handoff_exists: "Don't propose it again: the subtask already has a live handoff (details.handoff_id), whose outcome arrives as event=handoff_resolved.",
83
+ handoff_closed: 'Nothing more to do with this handoff: its card has already ended.',
84
+ };
76
85
  /**
77
86
  * Each of the contract's rate-limit buckets, in plain words. A bucket the
78
87
  * contract adds later is shown by retry_after alone, as the contract asks.
@@ -83,6 +92,13 @@ const RATE_BUCKETS = {
83
92
  token_writes_per_day: 'writes per day for this token',
84
93
  org_requests_per_minute: 'requests per minute for the whole org, across all its tokens',
85
94
  user_requests_per_minute: 'requests per minute for your account, across all your tokens',
95
+ session_polls_per_minute: 'polls per minute for this session',
96
+ session_typing_per_minute: 'typing notices per minute for this session',
97
+ session_approvals_per_minute: 'tool approval calls per minute for this session',
98
+ session_handoffs_per_minute: 'handoff calls per minute for this session',
99
+ connect_starts_per_hour: 'connections started per hour from this address',
100
+ connect_pending_grants: 'connections waiting for approval on this server',
101
+ connect_pending_per_address: 'connections waiting for approval from this network',
86
102
  };
87
103
  /** The release operations, as plan_release's actions. */
88
104
  const RELEASE_ACTIONS = {
@@ -284,7 +300,7 @@ export function describeApiError(err) {
284
300
  const kindHint = err.code === 'not_found' && typeof details.kind === 'string'
285
301
  ? NOT_FOUND_HINTS[details.kind]
286
302
  : undefined;
287
- const hint = kindHint ?? hintFor(err.code, err.hint);
303
+ const hint = kindHint ?? SERVER_HINTS[err.code] ?? hintFor(err.code, err.hint);
288
304
  if (hint && !(waitShown && RETRY_ADVICE.test(hint))) {
289
305
  const text = clean(inServerTerms(hint));
290
306
  if (!alreadySaid(text, said)) {
@@ -316,6 +332,38 @@ export function describeApiError(err) {
316
332
  return lines.join('\n');
317
333
  }
318
334
  /** A tool result for any failure inside a tool handler. */
335
+ /** The codes that mean an agent session is over: its token is no longer accepted. */
336
+ export const SESSION_OVER_CODES = new Set([
337
+ 'token_revoked',
338
+ 'token_expired',
339
+ 'token_invalid',
340
+ ]);
341
+ const SESSION_OVER_WHY = {
342
+ token_revoked: 'it was revoked',
343
+ token_expired: 'it expired after 30 days unused',
344
+ token_invalid: 'buildIt.Social no longer recognises its token',
345
+ };
346
+ /**
347
+ * What to tell the agent once a session token is refused for good: a
348
+ * session is reconnected with `connect`, never with a new personal access
349
+ * token.
350
+ */
351
+ export function sessionEndedText(code) {
352
+ const why = SESSION_OVER_WHY[code] ?? code;
353
+ return `This buildIt.Social session has ended (${why}). In this folder run \`buildit-mcp connect --project <KEY>\`, approve it, then restart Claude Code. Tell the person; retrying won't help.`;
354
+ }
355
+ /** A tool result for a call made after (or refused because) the session ended. */
356
+ export function sessionEndedResult(code) {
357
+ return {
358
+ isError: true,
359
+ content: [
360
+ {
361
+ type: 'text',
362
+ text: `buildit-mcp error: session_ended (${code})\n${sessionEndedText(code)}`,
363
+ },
364
+ ],
365
+ };
366
+ }
319
367
  export function toolErrorResult(err, requestId) {
320
368
  if (err instanceof ApiError) {
321
369
  return { isError: true, content: [{ type: 'text', text: describeApiError(err) }] };