@launchchair/agent 0.4.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.
@@ -0,0 +1,1101 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * LaunchChair MCP bridge (stdio).
5
+ *
6
+ * Implements minimal MCP tools over stdio so desktop clients can:
7
+ * - heartbeat connection
8
+ * - claim queued runs
9
+ * - complete runs
10
+ * - run a queued prompt end-to-end
11
+ * - continue a project loop until human input, completion, or max steps
12
+ */
13
+
14
+ import { spawn } from "node:child_process";
15
+ import path from "node:path";
16
+ import process from "node:process";
17
+ import { fileURLToPath } from "node:url";
18
+ import { buildRunnerCommand, parseRunnerCommand } from "./runner-command.mjs";
19
+
20
+ const VERSION = "0.4.0";
21
+ const SERVER_NAME = "launchchair-agent-mcp";
22
+ const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
23
+ const BASE_SERVER_INSTRUCTIONS =
24
+ "Use LaunchChair MCP tools—not web browsing—for LaunchChair project discovery, creation, planning, and loops. Call the most specific available tool. If a requested workflow is unavailable, explain the access or setup issue instead of silently substituting another project type or workflow.";
25
+
26
+ function nodeScriptCommand(scriptName) {
27
+ return `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(AGENT_SCRIPT_DIR, scriptName))}`;
28
+ }
29
+
30
+ function env(name, fallback = "") {
31
+ const value = process.env[name];
32
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
33
+ }
34
+
35
+ function toInt(value, fallback) {
36
+ const parsed = Number.parseInt(String(value ?? ""), 10);
37
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
38
+ }
39
+
40
+ function trimToBytes(input, maxBytes) {
41
+ const text = String(input ?? "");
42
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
43
+ const prefix = text.slice(0, Math.floor(text.length * 0.45));
44
+ const suffix = text.slice(Math.floor(text.length * 0.85));
45
+ const marker = "\n\n...[truncated by mcp bridge]...\n\n";
46
+ let result = `${prefix}${marker}${suffix}`;
47
+ while (Buffer.byteLength(result, "utf8") > maxBytes && result.length > 64) {
48
+ result = result.slice(0, Math.floor(result.length * 0.9));
49
+ }
50
+ return result;
51
+ }
52
+
53
+ function normalizeTarget(value, fallback = "other") {
54
+ const normalized = String(value ?? "")
55
+ .trim()
56
+ .toLowerCase()
57
+ .replace(/\s+/g, "_");
58
+ if (["chatgpt", "claude", "codex", "claude_code", "other"].includes(normalized)) return normalized;
59
+ return fallback;
60
+ }
61
+
62
+ function hasOpenAiKey() {
63
+ return String(process.env.OPENAI_API_KEY ?? "").trim().length > 0;
64
+ }
65
+
66
+ function hasAnthropicKey() {
67
+ return String(process.env.ANTHROPIC_API_KEY ?? "").trim().length > 0;
68
+ }
69
+
70
+ function builtInRunnerCommand(target) {
71
+ if (target === "codex") {
72
+ return nodeScriptCommand("codex-runner.mjs");
73
+ }
74
+ if (target === "claude_code") {
75
+ return nodeScriptCommand("claude-code-runner.mjs");
76
+ }
77
+ if (target === "chatgpt" && hasOpenAiKey()) {
78
+ return nodeScriptCommand("chatgpt-runner.mjs");
79
+ }
80
+ if (target === "claude" && hasAnthropicKey()) {
81
+ return nodeScriptCommand("claude-runner.mjs");
82
+ }
83
+ return "";
84
+ }
85
+
86
+ const config = {
87
+ baseUrl: env("LAUNCHCHAIR_BASE_URL", "http://localhost:3000").replace(/\/+$/, ""),
88
+ token: env("LAUNCHCHAIR_AGENT_TOKEN", env("LAUNCHCHAIR_API_KEY")),
89
+ projectId: env("LAUNCHCHAIR_PROJECT_ID") || null,
90
+ clientName: env("LC_AGENT_CLIENT_NAME", "MCP Bridge"),
91
+ clientType: env("LC_AGENT_CLIENT_TYPE", "other"),
92
+ clientVersion: env("LC_AGENT_CLIENT_VERSION", VERSION),
93
+ maxPayloadBytes: toInt(process.env.LC_AGENT_MAX_PAYLOAD_BYTES, 850 * 1024),
94
+ mockResponse: process.env.AGENT_MOCK_RESPONSE ?? "",
95
+ modeTargets: {
96
+ gpt: normalizeTarget(env("LC_AGENT_ROUTER_GPT_TARGET", "codex"), "codex"),
97
+ codex: normalizeTarget(env("LC_AGENT_ROUTER_CODEX_TARGET", "codex"), "codex"),
98
+ },
99
+ runnerCommands: {
100
+ generic: env("LC_AGENT_RUNNER_CMD"),
101
+ gpt: env("LC_AGENT_RUNNER_CMD_GPT"),
102
+ codex: env("LC_AGENT_RUNNER_CMD_CODEX"),
103
+ chatgpt: env("LC_AGENT_RUNNER_CMD_CHATGPT", builtInRunnerCommand("chatgpt")),
104
+ claude: env("LC_AGENT_RUNNER_CMD_CLAUDE", builtInRunnerCommand("claude")),
105
+ codexTarget: env("LC_AGENT_RUNNER_CMD_CODEX", builtInRunnerCommand("codex")),
106
+ claudeCode: env("LC_AGENT_RUNNER_CMD_CLAUDE_CODE", builtInRunnerCommand("claude_code")),
107
+ },
108
+ localRepoPaths: {
109
+ codex: env("LC_AGENT_CODEX_CWD", process.cwd()),
110
+ claudeCode: env("LC_AGENT_CLAUDE_CODE_CWD", process.cwd()),
111
+ },
112
+ };
113
+
114
+ if (!config.token) {
115
+ // eslint-disable-next-line no-console
116
+ console.error("Missing LAUNCHCHAIR_AGENT_TOKEN for MCP bridge. LAUNCHCHAIR_API_KEY is also supported as an alias.");
117
+ process.exit(1);
118
+ }
119
+
120
+ if (
121
+ !config.mockResponse &&
122
+ !config.runnerCommands.generic &&
123
+ !config.runnerCommands.gpt &&
124
+ !config.runnerCommands.codex &&
125
+ !config.runnerCommands.chatgpt &&
126
+ !config.runnerCommands.claude &&
127
+ !config.runnerCommands.codexTarget &&
128
+ !config.runnerCommands.claudeCode
129
+ ) {
130
+ // eslint-disable-next-line no-console
131
+ console.error(
132
+ "No runner command configured for MCP bridge. Set LC_AGENT_RUNNER_CMD* or use the built-in Codex/Claude Code runners; ChatGPT/Claude API fallbacks require provider API keys.",
133
+ );
134
+ process.exit(1);
135
+ }
136
+
137
+ async function api(path, { method = "GET", body } = {}) {
138
+ const response = await fetch(`${config.baseUrl}${path}`, {
139
+ method,
140
+ headers: {
141
+ Authorization: `Bearer ${config.token}`,
142
+ "Content-Type": "application/json",
143
+ },
144
+ body: body === undefined ? undefined : JSON.stringify(body),
145
+ });
146
+
147
+ let payload = null;
148
+ try {
149
+ payload = await response.json();
150
+ } catch (_error) {
151
+ payload = null;
152
+ }
153
+
154
+ if (!response.ok) {
155
+ const err = new Error(`HTTP ${response.status}: ${String(payload?.error ?? response.statusText ?? "Request failed")}`);
156
+ err.status = response.status;
157
+ err.payload = payload;
158
+ err.retryAfterSeconds = Number.parseInt(response.headers.get("retry-after") ?? "", 10) || null;
159
+ throw err;
160
+ }
161
+ return payload;
162
+ }
163
+
164
+ let statusPromise = null;
165
+
166
+ function loadStatus() {
167
+ if (!statusPromise) {
168
+ statusPromise = api("/api/agent/v1").catch(() => null);
169
+ }
170
+ return statusPromise;
171
+ }
172
+
173
+ function serverInstructions(status) {
174
+ const featureInstructions = String(status?.capabilities?.mcpServerInstructions ?? "").trim();
175
+ return featureInstructions ? `${BASE_SERVER_INSTRUCTIONS} ${featureInstructions}` : BASE_SERVER_INSTRUCTIONS;
176
+ }
177
+
178
+ function targetForRun(run) {
179
+ const mode = String(run?.promptMode ?? "").trim().toLowerCase();
180
+ if (mode === "gpt") return config.modeTargets.gpt;
181
+ if (mode === "codex") return config.modeTargets.codex;
182
+ return "other";
183
+ }
184
+
185
+ function commandForRun(run) {
186
+ const mode = String(run?.promptMode ?? "").trim().toLowerCase();
187
+ const target = targetForRun(run);
188
+ if (mode === "gpt") {
189
+ if (target === "codex" && config.runnerCommands.codexTarget) return config.runnerCommands.codexTarget;
190
+ if (target === "claude_code" && config.runnerCommands.claudeCode) return config.runnerCommands.claudeCode;
191
+ if (target === "chatgpt" && config.runnerCommands.chatgpt) return config.runnerCommands.chatgpt;
192
+ if (target === "claude" && config.runnerCommands.claude) return config.runnerCommands.claude;
193
+ if (config.runnerCommands.gpt) return config.runnerCommands.gpt;
194
+ return config.runnerCommands.generic;
195
+ }
196
+ if (mode === "codex") {
197
+ if (target === "codex" && config.runnerCommands.codexTarget) return config.runnerCommands.codexTarget;
198
+ if (target === "claude_code" && config.runnerCommands.claudeCode) return config.runnerCommands.claudeCode;
199
+ if (config.runnerCommands.codex) return config.runnerCommands.codex;
200
+ return config.runnerCommands.generic;
201
+ }
202
+ return config.runnerCommands.generic;
203
+ }
204
+
205
+ async function executeRunner(run) {
206
+ if (config.mockResponse) {
207
+ return {
208
+ stdout: config.mockResponse,
209
+ stderr: "",
210
+ exitCode: 0,
211
+ durationMs: 0,
212
+ };
213
+ }
214
+
215
+ const template = commandForRun(run);
216
+ if (!template) {
217
+ throw new Error(
218
+ `No runner command configured for promptMode=${String(run?.promptMode ?? "unknown")} target=${targetForRun(run)}.`,
219
+ );
220
+ }
221
+ const command = buildRunnerCommand(template, run);
222
+ const parsedCommand = parseRunnerCommand(command);
223
+ const startedAt = Date.now();
224
+ return new Promise((resolve, reject) => {
225
+ const child = spawn(parsedCommand.file, parsedCommand.args, {
226
+ shell: false,
227
+ stdio: ["pipe", "pipe", "pipe"],
228
+ env: {
229
+ ...process.env,
230
+ LC_AGENT_RUN_ID: String(run.id ?? ""),
231
+ LC_AGENT_PROJECT_ID: String(run.projectId ?? ""),
232
+ LC_AGENT_PHASE_KEY: String(run.phaseKey ?? ""),
233
+ LC_AGENT_BUCKET_KEY: String(run.bucketKey ?? ""),
234
+ LC_AGENT_CARD_ID: String(run.cardId ?? ""),
235
+ LC_AGENT_PROMPT_MODE: String(run.promptMode ?? ""),
236
+ LC_AGENT_RUN_TYPE: String(run.runType ?? ""),
237
+ },
238
+ });
239
+ let stdout = "";
240
+ let stderr = "";
241
+ child.stdout.on("data", (chunk) => {
242
+ stdout += chunk.toString();
243
+ });
244
+ child.stderr.on("data", (chunk) => {
245
+ stderr += chunk.toString();
246
+ });
247
+ child.on("error", (error) => reject(error));
248
+ child.on("close", (code) => {
249
+ const durationMs = Date.now() - startedAt;
250
+ if ((code ?? 1) !== 0) {
251
+ const error = new Error(`Runner exited with code ${String(code ?? 1)}${stderr ? `: ${stderr.trim()}` : ""}`);
252
+ error.code = code ?? 1;
253
+ error.stdout = stdout;
254
+ error.stderr = stderr;
255
+ error.durationMs = durationMs;
256
+ reject(error);
257
+ return;
258
+ }
259
+ resolve({
260
+ stdout,
261
+ stderr,
262
+ exitCode: code ?? 0,
263
+ durationMs,
264
+ });
265
+ });
266
+ child.stdin.write(String(run.prompt ?? ""));
267
+ child.stdin.end();
268
+ });
269
+ }
270
+
271
+ async function heartbeat(args = {}) {
272
+ const projectId = await resolveProjectId(args, false);
273
+ return api("/api/agent/v1/heartbeat", {
274
+ method: "POST",
275
+ body: {
276
+ projectId,
277
+ clientName: String(args.clientName ?? "").trim() || config.clientName,
278
+ clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
279
+ clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
280
+ lastAction: String(args.lastAction ?? "").trim() || "heartbeat",
281
+ lastActionAt: new Date().toISOString(),
282
+ },
283
+ });
284
+ }
285
+
286
+ async function claimNextRun(args = {}) {
287
+ const projectId = await resolveProjectId(args, false);
288
+ const payload = await api("/api/agent/v1/runs/next", {
289
+ method: "POST",
290
+ body: {
291
+ projectId,
292
+ clientName: String(args.clientName ?? "").trim() || config.clientName,
293
+ clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
294
+ clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
295
+ },
296
+ });
297
+ return payload.run ?? null;
298
+ }
299
+
300
+ async function completeRun(args = {}) {
301
+ const runId = String(args.runId ?? "").trim();
302
+ if (!runId) {
303
+ throw new Error("runId is required.");
304
+ }
305
+ return api(`/api/agent/v1/runs/${encodeURIComponent(runId)}/complete`, {
306
+ method: "POST",
307
+ body: {
308
+ status: String(args.status ?? "").trim().toLowerCase() === "failed" ? "failed" : "completed",
309
+ resultRaw: trimToBytes(String(args.resultRaw ?? ""), config.maxPayloadBytes),
310
+ resultJsonPayload: trimToBytes(String(args.resultJsonPayload ?? ""), config.maxPayloadBytes),
311
+ resultMeta: typeof args.resultMeta === "object" && args.resultMeta && !Array.isArray(args.resultMeta) ? args.resultMeta : {},
312
+ error: String(args.error ?? "").trim() || null,
313
+ clientName: String(args.clientName ?? "").trim() || config.clientName,
314
+ clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
315
+ clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
316
+ },
317
+ });
318
+ }
319
+
320
+ async function runOnce(args = {}) {
321
+ const run = await claimNextRun(args);
322
+ if (!run) {
323
+ return { run: null, completed: null };
324
+ }
325
+ try {
326
+ const execution = await executeRunner(run);
327
+ const stdout = String(execution.stdout ?? "").trim();
328
+ const stderr = String(execution.stderr ?? "").trim();
329
+ const output = stdout || stderr;
330
+ const completion = await api(`/api/agent/v1/runs/${encodeURIComponent(String(run.id))}/complete`, {
331
+ method: "POST",
332
+ body: {
333
+ status: "completed",
334
+ resultRaw: trimToBytes(output, config.maxPayloadBytes),
335
+ resultJsonPayload: trimToBytes(output, config.maxPayloadBytes),
336
+ resultMeta: {
337
+ exitCode: execution.exitCode ?? 0,
338
+ durationMs: execution.durationMs ?? 0,
339
+ target: targetForRun(run),
340
+ autoApplyRequested: run.autoApply === true,
341
+ runIntent: run.runIntent ?? "initial",
342
+ attemptNumber: run.attemptNumber ?? 1,
343
+ maxAttempts: run.maxAttempts ?? 1,
344
+ },
345
+ error: null,
346
+ clientName: String(args.clientName ?? "").trim() || config.clientName,
347
+ clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
348
+ clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
349
+ },
350
+ });
351
+ return { run, completed: completion?.run ?? null };
352
+ } catch (error) {
353
+ const message = error instanceof Error ? error.message : String(error ?? "Runner failed");
354
+ const stdout = typeof error?.stdout === "string" ? error.stdout : "";
355
+ const stderr = typeof error?.stderr === "string" ? error.stderr : "";
356
+ const completion = await api(`/api/agent/v1/runs/${encodeURIComponent(String(run.id))}/complete`, {
357
+ method: "POST",
358
+ body: {
359
+ status: "failed",
360
+ resultRaw: trimToBytes(`${stdout}\n${stderr}`.trim() || message, config.maxPayloadBytes),
361
+ resultJsonPayload: "",
362
+ resultMeta: {
363
+ exitCode: typeof error?.code === "number" ? error.code : null,
364
+ durationMs: typeof error?.durationMs === "number" ? error.durationMs : null,
365
+ target: targetForRun(run),
366
+ },
367
+ error: message,
368
+ clientName: String(args.clientName ?? "").trim() || config.clientName,
369
+ clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
370
+ clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
371
+ },
372
+ });
373
+ return { run, completed: completion?.run ?? null, error: message };
374
+ }
375
+ }
376
+
377
+ async function listProjects(args = {}) {
378
+ const params = new URLSearchParams();
379
+ const query = String(args.query ?? args.projectName ?? "").trim();
380
+ const limit = Number.parseInt(String(args.limit ?? ""), 10);
381
+ if (query) {
382
+ params.set("query", query);
383
+ }
384
+ if (Number.isFinite(limit) && limit > 0) {
385
+ params.set("limit", String(Math.min(limit, 50)));
386
+ }
387
+ const suffix = params.toString();
388
+ return api(`/api/agent/v1/projects${suffix ? `?${suffix}` : ""}`);
389
+ }
390
+
391
+ function normalizeProjectName(value) {
392
+ return String(value ?? "")
393
+ .trim()
394
+ .toLowerCase()
395
+ .replace(/[^a-z0-9]+/g, " ")
396
+ .replace(/\s+/g, " ")
397
+ .trim();
398
+ }
399
+
400
+ async function resolveProjectId(args = {}, required = true) {
401
+ const explicitProjectId = String(args.projectId ?? "").trim() || config.projectId || "";
402
+ if (explicitProjectId) {
403
+ return explicitProjectId;
404
+ }
405
+
406
+ const projectName = String(args.projectName ?? args.query ?? "").trim();
407
+ if (!projectName) {
408
+ if (!required) {
409
+ return null;
410
+ }
411
+ throw new Error("projectId is required. Pass projectId, set LAUNCHCHAIR_PROJECT_ID, or pass projectName.");
412
+ }
413
+
414
+ const listed = await listProjects({ query: projectName, limit: 10 });
415
+ const projects = Array.isArray(listed?.projects) ? listed.projects : [];
416
+ const normalizedName = normalizeProjectName(projectName);
417
+ const exactMatch = projects.find((project) => normalizeProjectName(project?.name) === normalizedName);
418
+ const selected = exactMatch ?? (projects.length === 1 ? projects[0] : null);
419
+ const resolvedProjectId = String(selected?.id ?? "").trim();
420
+ if (resolvedProjectId) {
421
+ config.projectId = resolvedProjectId;
422
+ return resolvedProjectId;
423
+ }
424
+
425
+ const names = projects.map((project) => `${String(project?.name ?? "Untitled")} (${String(project?.id ?? "unknown")})`);
426
+ throw new Error(
427
+ names.length > 0
428
+ ? `Could not resolve a unique LaunchChair project for "${projectName}". Matching projects: ${names.join(", ")}. Pass projectId.`
429
+ : `Could not find a LaunchChair project named "${projectName}" for this token.`,
430
+ );
431
+ }
432
+
433
+ function normalizeChoiceSelections(args = {}) {
434
+ if (Array.isArray(args.choiceSelections)) {
435
+ return args.choiceSelections.filter((item) => item && typeof item === "object" && !Array.isArray(item));
436
+ }
437
+ if (args.choiceSelection && typeof args.choiceSelection === "object" && !Array.isArray(args.choiceSelection)) {
438
+ return [args.choiceSelection];
439
+ }
440
+ if (Array.isArray(args.generatedChoices)) {
441
+ return args.generatedChoices.filter((item) => item && typeof item === "object" && !Array.isArray(item));
442
+ }
443
+ if (args.generatedChoices && typeof args.generatedChoices === "object" && !Array.isArray(args.generatedChoices)) {
444
+ if (typeof args.generatedChoices.domain === "string") {
445
+ return [args.generatedChoices];
446
+ }
447
+ return ["wedge", "pricing", "feature"].flatMap((domain) => {
448
+ const value = args.generatedChoices[domain];
449
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
450
+ return [];
451
+ }
452
+ return [{ domain, ...value }];
453
+ });
454
+ }
455
+ return [];
456
+ }
457
+
458
+ async function selectChoice(args = {}) {
459
+ const projectId = await resolveProjectId(args);
460
+ if (!projectId) {
461
+ throw new Error("projectId is required. Pass projectId, set LAUNCHCHAIR_PROJECT_ID, or pass projectName.");
462
+ }
463
+ return api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/choices/select`, {
464
+ method: "POST",
465
+ body: {
466
+ domain: String(args.domain ?? "").trim() || undefined,
467
+ phaseKey: String(args.phaseKey ?? "").trim() || undefined,
468
+ bucketKey: String(args.bucketKey ?? "").trim() || undefined,
469
+ optionId: String(args.optionId ?? "").trim() || undefined,
470
+ optionTitle: String(args.optionTitle ?? "").trim() || undefined,
471
+ optionQuery: String(args.optionQuery ?? "").trim() || undefined,
472
+ resetDownstream: args.resetDownstream !== false,
473
+ },
474
+ });
475
+ }
476
+
477
+ async function continueProjectLoop(args = {}) {
478
+ const projectId = await resolveProjectId(args);
479
+ if (!projectId) {
480
+ throw new Error("projectId is required. Pass projectId, set LAUNCHCHAIR_PROJECT_ID, or pass projectName.");
481
+ }
482
+ const maxSteps = Math.min(Math.max(toInt(args.maxSteps, 8), 1), 25);
483
+ const stopPhaseKey = String(args.stopPhaseKey ?? "").trim();
484
+ const completionTarget =
485
+ String(args.completionTarget ?? "").trim() === "build_board_ready" ? "build_board_ready" : undefined;
486
+ const ideaSnapshot =
487
+ typeof args.ideaSnapshot === "object" && args.ideaSnapshot !== null && !Array.isArray(args.ideaSnapshot)
488
+ ? args.ideaSnapshot
489
+ : null;
490
+ const runnerTarget = config.modeTargets.codex === "claude_code" ? "claude_code" : config.modeTargets.codex;
491
+ const localRepoPath =
492
+ runnerTarget === "claude_code" ? config.localRepoPaths.claudeCode : config.localRepoPaths.codex;
493
+ const agentSetup = {
494
+ localRepoPath,
495
+ runnerTarget,
496
+ clientType: config.clientType,
497
+ ...(typeof args.agentSetup === "object" && args.agentSetup !== null && !Array.isArray(args.agentSetup)
498
+ ? args.agentSetup
499
+ : {}),
500
+ };
501
+ let pendingChoiceSelections = normalizeChoiceSelections(args);
502
+ const steps = [];
503
+ let final = null;
504
+
505
+ for (let index = 0; index < maxSteps; index += 1) {
506
+ const loop = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/loop/continue`, {
507
+ method: "POST",
508
+ body: {
509
+ ...(stopPhaseKey ? { stopPhaseKey } : {}),
510
+ ...(completionTarget ? { completionTarget } : {}),
511
+ ...(ideaSnapshot ? { ideaSnapshot } : {}),
512
+ ...(pendingChoiceSelections.length > 0 ? { choiceSelections: pendingChoiceSelections } : {}),
513
+ agentSetup,
514
+ },
515
+ });
516
+ pendingChoiceSelections = [];
517
+
518
+ if (loop.status !== "queued_run" && loop.status !== "waiting_for_run") {
519
+ final = loop;
520
+ break;
521
+ }
522
+
523
+ const run = loop.run;
524
+ if (!run?.id || !run?.prompt) {
525
+ final = loop;
526
+ break;
527
+ }
528
+
529
+ const execution = await executeRunner(run);
530
+ const stdout = String(execution.stdout ?? "").trim();
531
+ const stderr = String(execution.stderr ?? "").trim();
532
+ const output = stdout || stderr;
533
+ const completion = await api(`/api/agent/v1/runs/${encodeURIComponent(String(run.id))}/complete`, {
534
+ method: "POST",
535
+ body: {
536
+ status: "completed",
537
+ resultRaw: trimToBytes(output, config.maxPayloadBytes),
538
+ resultJsonPayload: trimToBytes(output, config.maxPayloadBytes),
539
+ resultMeta: {
540
+ exitCode: execution.exitCode ?? 0,
541
+ durationMs: execution.durationMs ?? 0,
542
+ target: targetForRun(run),
543
+ autoApplyRequested: run.autoApply === true,
544
+ runIntent: run.runIntent ?? "initial",
545
+ attemptNumber: run.attemptNumber ?? 1,
546
+ maxAttempts: run.maxAttempts ?? 1,
547
+ projectLoopStep: index + 1,
548
+ },
549
+ error: null,
550
+ clientName: String(args.clientName ?? "").trim() || config.clientName,
551
+ clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
552
+ clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
553
+ },
554
+ });
555
+
556
+ steps.push({
557
+ step: index + 1,
558
+ loop,
559
+ completed: completion?.run ?? null,
560
+ phaseComplete: completion?.run?.resultMeta?.loop?.phaseComplete ?? null,
561
+ queuedChildRun: completion?.run?.queuedChildRun ?? null,
562
+ });
563
+
564
+ const decision = String(completion?.run?.resultMeta?.loop?.decision ?? "");
565
+ if (decision.includes("failed") || decision.includes("blocked") || decision === "loop_error") {
566
+ final = {
567
+ status: "stopped_after_failed_step",
568
+ projectId,
569
+ message: `Project loop stopped after ${decision}.`,
570
+ };
571
+ break;
572
+ }
573
+ }
574
+
575
+ if (!final) {
576
+ final = {
577
+ status: "max_steps_reached",
578
+ projectId,
579
+ message: `Project loop stopped after maxSteps=${maxSteps}. Run again to continue.`,
580
+ };
581
+ }
582
+
583
+ return { projectId, stopPhaseKey: stopPhaseKey || null, completionTarget: completionTarget ?? null, steps, final };
584
+ }
585
+
586
+ async function createProject(args = {}) {
587
+ const requestedProjectKind = String(args.projectKind ?? "").trim().toLowerCase();
588
+ const featureProject = requestedProjectKind === "feature";
589
+ const fullLoop = featureProject && args.fullLoop === true;
590
+ const featureSnapshot =
591
+ typeof args.featureSnapshot === "object" && args.featureSnapshot !== null && !Array.isArray(args.featureSnapshot)
592
+ ? args.featureSnapshot
593
+ : undefined;
594
+ const planToBuildBoard = featureProject && (fullLoop || args.planToBuildBoard !== false);
595
+ if (featureProject) {
596
+ const description = String(
597
+ featureSnapshot?.description ??
598
+ featureSnapshot?.featureDescription ??
599
+ featureSnapshot?.desiredOutcome ??
600
+ args.oneLiner ??
601
+ "",
602
+ ).trim();
603
+ const problem = String(featureSnapshot?.problem ?? "").trim();
604
+ if (!featureSnapshot || !description || !problem) {
605
+ throw new Error(
606
+ "Feature project creation requires featureSnapshot with description and problem. Infer both from the user's request and available product context before calling this tool.",
607
+ );
608
+ }
609
+ }
610
+ const createArgs = { ...args };
611
+ delete createArgs.planToBuildBoard;
612
+ delete createArgs.maxSteps;
613
+ delete createArgs.fullLoop;
614
+ delete createArgs.buildMaxSteps;
615
+ const body = {
616
+ ...createArgs,
617
+ name: String(args.name ?? args.projectName ?? "").trim(),
618
+ oneLiner: String(args.oneLiner ?? "").trim(),
619
+ projectKind: requestedProjectKind || undefined,
620
+ projectType: featureProject ? undefined : String(args.projectType ?? "").trim() || undefined,
621
+ startLoop: planToBuildBoard ? false : args.startLoop === true,
622
+ stopPhaseKey: String(args.stopPhaseKey ?? "").trim() || undefined,
623
+ ideaSnapshot:
624
+ typeof args.ideaSnapshot === "object" && args.ideaSnapshot !== null && !Array.isArray(args.ideaSnapshot)
625
+ ? args.ideaSnapshot
626
+ : undefined,
627
+ featureSnapshot,
628
+ };
629
+ const payload = await api("/api/agent/v1/projects", {
630
+ method: "POST",
631
+ body,
632
+ });
633
+ const projectId = String(payload?.project?.id ?? payload?.projectId ?? "").trim();
634
+ if (projectId) {
635
+ config.projectId = projectId;
636
+ }
637
+ if (!planToBuildBoard || !projectId) {
638
+ return payload;
639
+ }
640
+
641
+ const planning = await continueProjectLoop({
642
+ projectId,
643
+ stopPhaseKey: "build",
644
+ completionTarget: "build_board_ready",
645
+ maxSteps: args.maxSteps ?? 12,
646
+ });
647
+ const build =
648
+ fullLoop && planning.final?.status === "build_board_ready"
649
+ ? await continueProjectLoop({
650
+ projectId,
651
+ stopPhaseKey: "build",
652
+ completionTarget: "phase_complete",
653
+ maxSteps: args.buildMaxSteps ?? 25,
654
+ })
655
+ : null;
656
+ return {
657
+ ...payload,
658
+ planning,
659
+ ...(build ? { build } : {}),
660
+ next: {
661
+ ...(payload?.next ?? {}),
662
+ planningStatus: planning.final?.status ?? "unknown",
663
+ ...(build ? { buildStatus: build.final?.status ?? "unknown" } : {}),
664
+ message:
665
+ build
666
+ ? build.final?.message ?? "Feature build loop stopped."
667
+ : planning.final?.status === "build_board_ready"
668
+ ? "Feature planning is complete and the Build Board is ready. No build cards were executed."
669
+ : planning.final?.message ?? "Feature project was created, but planning stopped before the Build Board was ready.",
670
+ },
671
+ };
672
+ }
673
+
674
+ const MCP_TOOLS = [
675
+ {
676
+ name: "launchchair_create_project",
677
+ description:
678
+ "Create a new LaunchChair Product project in the token workspace, optionally save the first Idea Snapshot, and optionally start the project loop. Requires a workspace-scoped token with projects.write.",
679
+ inputSchema: {
680
+ type: "object",
681
+ properties: {
682
+ name: { type: "string" },
683
+ projectName: { type: "string" },
684
+ oneLiner: { type: "string" },
685
+ projectType: { type: "string", enum: ["b2b", "b2c", "both", "free"] },
686
+ startLoop: { type: "boolean", description: "If true, LaunchChair immediately calls loop/continue after creation." },
687
+ stopPhaseKey: { type: "string", description: "Optional stop phase when startLoop is true." },
688
+ ideaSnapshot: {
689
+ type: "object",
690
+ description:
691
+ "Optional first-bucket seed. Include problem, targetUser, solution, and optional jobToBeDone/whyNow.",
692
+ properties: {
693
+ problem: { type: "string" },
694
+ targetUser: { type: "string" },
695
+ solution: { type: "string" },
696
+ jobToBeDone: { type: "string" },
697
+ whyNow: { type: "string" },
698
+ resetDownstream: { type: "boolean" },
699
+ },
700
+ required: ["problem", "targetUser", "solution"],
701
+ additionalProperties: false,
702
+ },
703
+ },
704
+ anyOf: [{ required: ["name"] }, { required: ["projectName"] }],
705
+ additionalProperties: false,
706
+ },
707
+ },
708
+ {
709
+ name: "launchchair_status",
710
+ description: "Read LaunchChair Agent API catalog and scopes.",
711
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
712
+ },
713
+ {
714
+ name: "launchchair_list_projects",
715
+ description:
716
+ "List LaunchChair projects visible to the current Agent API token. Use this to resolve a user-provided project name like LeafLore when LAUNCHCHAIR_PROJECT_ID is not configured.",
717
+ inputSchema: {
718
+ type: "object",
719
+ properties: {
720
+ query: { type: "string", description: "Optional project-name search." },
721
+ projectName: { type: "string", description: "Alias for query." },
722
+ limit: { type: "number", description: "Default 25, max 50." },
723
+ },
724
+ additionalProperties: false,
725
+ },
726
+ },
727
+ {
728
+ name: "launchchair_heartbeat",
729
+ description: "Send heartbeat and update connected agent metadata.",
730
+ inputSchema: {
731
+ type: "object",
732
+ properties: {
733
+ projectId: { type: "string" },
734
+ projectName: { type: "string" },
735
+ clientName: { type: "string" },
736
+ clientType: { type: "string" },
737
+ clientVersion: { type: "string" },
738
+ },
739
+ additionalProperties: false,
740
+ },
741
+ },
742
+ {
743
+ name: "launchchair_claim_next_run",
744
+ description: "Claim next queued Run with Agent job for token/project.",
745
+ inputSchema: {
746
+ type: "object",
747
+ properties: {
748
+ projectId: { type: "string" },
749
+ projectName: { type: "string" },
750
+ },
751
+ additionalProperties: false,
752
+ },
753
+ },
754
+ {
755
+ name: "launchchair_complete_run",
756
+ description: "Complete/fail a claimed run by id. LaunchChair handles configured apply, validation, retry, and remediation decisions after completion.",
757
+ inputSchema: {
758
+ type: "object",
759
+ properties: {
760
+ runId: { type: "string" },
761
+ status: { type: "string" },
762
+ resultRaw: { type: "string" },
763
+ resultJsonPayload: { type: "string" },
764
+ error: { type: "string" },
765
+ },
766
+ required: ["runId"],
767
+ additionalProperties: true,
768
+ },
769
+ },
770
+ {
771
+ name: "launchchair_run_once",
772
+ description: "Claim next queued run, execute local runner, then complete run.",
773
+ inputSchema: {
774
+ type: "object",
775
+ properties: {
776
+ projectId: { type: "string" },
777
+ projectName: { type: "string" },
778
+ },
779
+ additionalProperties: false,
780
+ },
781
+ },
782
+ {
783
+ name: "launchchair_select_choice",
784
+ description:
785
+ "Select or change a generated LaunchChair user-choice option, such as wedge, pricing, or feature direction. Use this instead of generic SPEC_UPDATE for user-choice buckets.",
786
+ inputSchema: {
787
+ type: "object",
788
+ properties: {
789
+ projectId: { type: "string" },
790
+ projectName: { type: "string" },
791
+ domain: { type: "string", enum: ["wedge", "pricing", "feature"] },
792
+ phaseKey: { type: "string" },
793
+ bucketKey: { type: "string" },
794
+ optionId: { type: "string" },
795
+ optionTitle: { type: "string" },
796
+ optionQuery: { type: "string" },
797
+ resetDownstream: {
798
+ type: "boolean",
799
+ description: "Defaults true. Reset downstream generated phases so the loop can rerun from the changed choice.",
800
+ },
801
+ },
802
+ anyOf: [{ required: ["domain"] }, { required: ["phaseKey", "bucketKey"] }],
803
+ additionalProperties: false,
804
+ },
805
+ },
806
+ {
807
+ name: "launchchair_continue_project",
808
+ description:
809
+ "Continue a LaunchChair project loop: inspect project state, queue the next agent-actionable step, execute it with the configured runner, complete it, apply/validate/remediate through LaunchChair, and return phase synthesis links when phases finish.",
810
+ inputSchema: {
811
+ type: "object",
812
+ properties: {
813
+ projectId: { type: "string" },
814
+ projectName: {
815
+ type: "string",
816
+ description: "Optional project name to resolve through launchchair_list_projects when projectId is omitted.",
817
+ },
818
+ stopPhaseKey: {
819
+ type: "string",
820
+ description: "Optional stop phase. When omitted, LaunchChair selects the correct default for the project workflow.",
821
+ },
822
+ completionTarget: {
823
+ type: "string",
824
+ enum: ["phase_complete", "build_board_ready"],
825
+ description: "Optional completion boundary. build_board_ready stops after board generation and before card execution when supported.",
826
+ },
827
+ maxSteps: { type: "number", description: "Default 8, max 25." },
828
+ choiceSelection: {
829
+ type: "object",
830
+ description:
831
+ "Optional generated-choice override to apply before continuing, for example { domain: 'wedge', optionTitle: 'Local Plant Walk Companion', resetDownstream: true }.",
832
+ properties: {
833
+ domain: { type: "string", enum: ["wedge", "pricing", "feature"] },
834
+ phaseKey: { type: "string" },
835
+ bucketKey: { type: "string" },
836
+ optionId: { type: "string" },
837
+ optionTitle: { type: "string" },
838
+ optionQuery: { type: "string" },
839
+ resetDownstream: { type: "boolean" },
840
+ },
841
+ additionalProperties: false,
842
+ },
843
+ choiceSelections: {
844
+ type: "array",
845
+ description: "Optional list of generated-choice overrides to apply once before continuing.",
846
+ items: {
847
+ type: "object",
848
+ properties: {
849
+ domain: { type: "string", enum: ["wedge", "pricing", "feature"] },
850
+ phaseKey: { type: "string" },
851
+ bucketKey: { type: "string" },
852
+ optionId: { type: "string" },
853
+ optionTitle: { type: "string" },
854
+ optionQuery: { type: "string" },
855
+ resetDownstream: { type: "boolean" },
856
+ },
857
+ additionalProperties: false,
858
+ },
859
+ },
860
+ generatedChoices: {
861
+ type: "object",
862
+ description:
863
+ "Backward-compatible alias for choiceSelection(s). Prefer choiceSelection. Supports { domain, optionTitle } or { wedge: { optionTitle } }.",
864
+ additionalProperties: true,
865
+ },
866
+ ideaSnapshot: {
867
+ type: "object",
868
+ description:
869
+ "Optional first-bucket seed for Product Ideation / Idea Snapshot. If the loop is blocked on idea_snapshot, LaunchChair saves this structured brief and continues.",
870
+ properties: {
871
+ projectName: { type: "string" },
872
+ oneLiner: { type: "string" },
873
+ projectType: { type: "string", enum: ["b2b", "b2c", "both", "free"] },
874
+ problem: { type: "string" },
875
+ targetUser: { type: "string" },
876
+ solution: { type: "string" },
877
+ jobToBeDone: { type: "string" },
878
+ whyNow: { type: "string" },
879
+ resetDownstream: { type: "boolean" },
880
+ },
881
+ required: ["problem", "targetUser", "solution"],
882
+ additionalProperties: false,
883
+ },
884
+ agentSetup: {
885
+ type: "object",
886
+ description:
887
+ "Optional Stack Setup evidence. The MCP bridge sends localRepoPath, runnerTarget, and clientType automatically.",
888
+ additionalProperties: true,
889
+ },
890
+ },
891
+ additionalProperties: false,
892
+ },
893
+ },
894
+ ];
895
+
896
+ const READ_ONLY_TOOLS = new Set(["launchchair_status", "launchchair_list_projects"]);
897
+
898
+ function annotateTool(tool) {
899
+ return {
900
+ ...tool,
901
+ annotations: {
902
+ ...(tool.annotations ?? {}),
903
+ readOnlyHint: READ_ONLY_TOOLS.has(tool.name),
904
+ destructiveHint: false,
905
+ openWorldHint: true,
906
+ },
907
+ };
908
+ }
909
+
910
+ function mergeToolOverrides(baseTools, overrides) {
911
+ const baseByName = new Map(baseTools.map((tool) => [tool.name, tool]));
912
+ const overrideByName = new Map(overrides.map((tool) => [String(tool?.name ?? ""), tool]));
913
+ const merged = baseTools.map((tool) => ({ ...tool, ...(overrideByName.get(tool.name) ?? {}) }));
914
+ for (const override of overrides) {
915
+ const name = String(override?.name ?? "").trim();
916
+ if (name && !baseByName.has(name)) {
917
+ merged.push(override);
918
+ }
919
+ }
920
+ return merged.map(annotateTool);
921
+ }
922
+
923
+ function writeMessage(obj) {
924
+ const body = Buffer.from(JSON.stringify(obj), "utf8");
925
+ process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
926
+ process.stdout.write(body);
927
+ }
928
+
929
+ function writeResult(id, result) {
930
+ writeMessage({ jsonrpc: "2.0", id, result });
931
+ }
932
+
933
+ function writeError(id, code, message, data = undefined) {
934
+ writeMessage({
935
+ jsonrpc: "2.0",
936
+ id,
937
+ error: { code, message, data },
938
+ });
939
+ }
940
+
941
+ function textResult(payload) {
942
+ return {
943
+ content: [
944
+ {
945
+ type: "text",
946
+ text: JSON.stringify(payload, null, 2),
947
+ },
948
+ ],
949
+ };
950
+ }
951
+
952
+ async function handleRpc(request) {
953
+ const method = String(request?.method ?? "");
954
+ const id = request?.id;
955
+ if (!method) {
956
+ if (id !== undefined) writeError(id, -32600, "Invalid Request");
957
+ return;
958
+ }
959
+
960
+ if (method === "initialize") {
961
+ const status = await loadStatus();
962
+ writeResult(id, {
963
+ protocolVersion: "2024-11-05",
964
+ capabilities: {
965
+ tools: {},
966
+ },
967
+ serverInfo: {
968
+ name: SERVER_NAME,
969
+ version: VERSION,
970
+ },
971
+ instructions: serverInstructions(status),
972
+ });
973
+ return;
974
+ }
975
+
976
+ if (method === "notifications/initialized") {
977
+ return;
978
+ }
979
+
980
+ if (method === "tools/list") {
981
+ const status = await loadStatus();
982
+ const overrides = Array.isArray(status?.capabilities?.mcpToolOverrides)
983
+ ? status.capabilities.mcpToolOverrides
984
+ : [];
985
+ const tools = mergeToolOverrides(MCP_TOOLS, overrides);
986
+ writeResult(id, { tools });
987
+ return;
988
+ }
989
+
990
+ if (method === "tools/call") {
991
+ const params = request?.params ?? {};
992
+ const toolName = String(params.name ?? "");
993
+ const args = typeof params.arguments === "object" && params.arguments ? params.arguments : {};
994
+ try {
995
+ if (toolName === "launchchair_status") {
996
+ const status = await api("/api/agent/v1");
997
+ writeResult(id, textResult(status));
998
+ return;
999
+ }
1000
+ if (toolName === "launchchair_create_project") {
1001
+ const payload = await createProject(args);
1002
+ writeResult(id, textResult(payload));
1003
+ return;
1004
+ }
1005
+ if (toolName === "launchchair_create_feature_project") {
1006
+ const payload = await createProject({ ...args, projectKind: "feature" });
1007
+ writeResult(id, textResult(payload));
1008
+ return;
1009
+ }
1010
+ if (toolName === "launchchair_list_projects") {
1011
+ const payload = await listProjects(args);
1012
+ writeResult(id, textResult(payload));
1013
+ return;
1014
+ }
1015
+ if (toolName === "launchchair_heartbeat") {
1016
+ const payload = await heartbeat(args);
1017
+ writeResult(id, textResult(payload));
1018
+ return;
1019
+ }
1020
+ if (toolName === "launchchair_claim_next_run") {
1021
+ const payload = await claimNextRun(args);
1022
+ writeResult(id, textResult({ run: payload }));
1023
+ return;
1024
+ }
1025
+ if (toolName === "launchchair_complete_run") {
1026
+ const payload = await completeRun(args);
1027
+ writeResult(id, textResult(payload));
1028
+ return;
1029
+ }
1030
+ if (toolName === "launchchair_run_once") {
1031
+ const payload = await runOnce(args);
1032
+ writeResult(id, textResult(payload));
1033
+ return;
1034
+ }
1035
+ if (toolName === "launchchair_select_choice") {
1036
+ const payload = await selectChoice(args);
1037
+ writeResult(id, textResult(payload));
1038
+ return;
1039
+ }
1040
+ if (toolName === "launchchair_continue_project") {
1041
+ const payload = await continueProjectLoop(args);
1042
+ writeResult(id, textResult(payload));
1043
+ return;
1044
+ }
1045
+ writeError(id, -32601, `Unknown tool: ${toolName}`);
1046
+ } catch (error) {
1047
+ writeResult(id, {
1048
+ content: [
1049
+ {
1050
+ type: "text",
1051
+ text: JSON.stringify(
1052
+ {
1053
+ error: error instanceof Error ? error.message : String(error ?? "Tool failed"),
1054
+ },
1055
+ null,
1056
+ 2,
1057
+ ),
1058
+ },
1059
+ ],
1060
+ isError: true,
1061
+ });
1062
+ }
1063
+ return;
1064
+ }
1065
+
1066
+ writeError(id, -32601, `Method not found: ${method}`);
1067
+ }
1068
+
1069
+ let buffer = Buffer.alloc(0);
1070
+ process.stdin.on("data", (chunk) => {
1071
+ buffer = Buffer.concat([buffer, chunk]);
1072
+ while (true) {
1073
+ const headerEnd = buffer.indexOf("\r\n\r\n");
1074
+ if (headerEnd === -1) break;
1075
+ const headerRaw = buffer.slice(0, headerEnd).toString("utf8");
1076
+ const lines = headerRaw.split("\r\n");
1077
+ let contentLength = -1;
1078
+ for (const line of lines) {
1079
+ const [key, value] = line.split(":");
1080
+ if (key && value && key.toLowerCase() === "content-length") {
1081
+ contentLength = Number.parseInt(value.trim(), 10);
1082
+ }
1083
+ }
1084
+ if (!Number.isFinite(contentLength) || contentLength < 0) {
1085
+ buffer = Buffer.alloc(0);
1086
+ break;
1087
+ }
1088
+ const bodyStart = headerEnd + 4;
1089
+ const bodyEnd = bodyStart + contentLength;
1090
+ if (buffer.length < bodyEnd) break;
1091
+ const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
1092
+ buffer = buffer.slice(bodyEnd);
1093
+ let parsed = null;
1094
+ try {
1095
+ parsed = JSON.parse(body);
1096
+ } catch (_error) {
1097
+ continue;
1098
+ }
1099
+ void handleRpc(parsed);
1100
+ }
1101
+ });