@launchchair/agent 0.4.12 → 0.5.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/AGENTS.md CHANGED
@@ -35,6 +35,7 @@ mkdir -p "$HOME/launchchair-agent"
35
35
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/local-bridge.mjs" -o "$HOME/launchchair-agent/local-bridge.mjs"
36
36
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/launchchair-cli.mjs" -o "$HOME/launchchair-agent/launchchair-cli.mjs"
37
37
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/mcp-bridge-server.mjs" -o "$HOME/launchchair-agent/mcp-bridge-server.mjs"
38
+ curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/mcp-capabilities.mjs" -o "$HOME/launchchair-agent/mcp-capabilities.mjs"
38
39
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/project-workspace.mjs" -o "$HOME/launchchair-agent/project-workspace.mjs"
39
40
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/runner-command.mjs" -o "$HOME/launchchair-agent/runner-command.mjs"
40
41
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/codex-runner.mjs" -o "$HOME/launchchair-agent/codex-runner.mjs"
@@ -75,7 +76,7 @@ LAUNCHCHAIR_BASE_URL="https://www.launchchair.io"
75
76
  LAUNCHCHAIR_PROJECT_ID="optional project UUID; omit or leave blank if creating or discovering a project"
76
77
  ```
77
78
 
78
- `launchchair login` is the recommended token path. It saves the token to `~/.launchchair/config.json`, and `launchchair mcp-server` reads that saved token. Use `LAUNCHCHAIR_AGENT_TOKEN` only as a manual fallback when browser login is skipped or a per-client token override is needed.
79
+ `launchchair login` saves the token to `~/.launchchair/config.json`, shared by the CLI and MCP. Use `LAUNCHCHAIR_AGENT_TOKEN` as a manual fallback when browser login is skipped. A fresh saved login takes precedence over stale environment credentials; logout disables authentication until login. Use a separate `LAUNCHCHAIR_CONFIG_DIR` for a separate per-client account.
79
80
  The packaged MCP and local bridges also read the same saved CLI config as a fallback, so a desktop MCP launch should not require pasting a token into Codex or Claude config after browser login. Guided setup verifies standards-compliant MCP initialize and tool discovery before it reports success.
80
81
 
81
82
  For Codex Desktop local runs, choose the runner globally and bind each project clone independently:
@@ -122,12 +123,12 @@ Prefer `launchchair setup codex` so the CLI writes native config and verifies MC
122
123
 
123
124
  ## What To Do First In A Fresh Chat
124
125
 
125
- 1. If LaunchChair MCP tools are available, call `launchchair_heartbeat`.
126
- 2. Call `launchchair_status` to confirm token, bridge, runner, and project context.
126
+ 1. Call `launchchair_status`. If login is required, call `launchchair_login`, present the approval URL, then poll `launchchair_login_status` at its returned interval.
127
+ 2. If paid access is required, present the returned upgrade URL and wait for the user to upgrade before resuming the blocked operation.
127
128
  3. If `LAUNCHCHAIR_PROJECT_ID` is blank and the user named a project, call `launchchair_list_projects` and select the matching project.
128
129
  4. If the user asks to create a new LaunchChair project, call `launchchair_create_project`.
129
- 5. If the user clicked Run with agent in LaunchChair, call `launchchair_run_once`.
130
- 6. If the user asks to advance the product workflow, call `launchchair_continue_project`.
130
+ 5. To pick up queued work or advance the workflow, call `launchchair_next_step`, execute its claimed prompt in the current host agent, and submit the result with `launchchair_complete_run`.
131
+ 6. Use `launchchair_run_once` or `executionMode=local_runner` only when the user explicitly requests a separate local runner.
131
132
  7. If LaunchChair asks for a user-choice bucket such as wedge, pricing, or feature direction, call `launchchair_select_choice`. Do not use generic SPEC_UPDATE apply for user-choice buckets.
132
133
  8. When the user asks for validation scores or wants to compare ideas, call `launchchair_get_scorecards`. Report validation scorecards separately from generated direction-option scores.
133
134
 
@@ -218,7 +219,7 @@ Avoid long explanations of local file searching when LaunchChair MCP tools are a
218
219
  Run a full project loop:
219
220
 
220
221
  ```text
221
- Call launchchair_continue_project with projectId, maxSteps, and optional stopPhaseKey.
222
+ Call launchchair_next_step with projectId and optional stopPhaseKey, execute the claimed prompt with the host's tools, and call launchchair_complete_run. Repeat until the requested target or a genuine checkpoint.
222
223
  ```
223
224
 
224
225
  Create a project from a new idea:
@@ -233,18 +234,20 @@ Find a project by name:
233
234
  Call launchchair_list_projects with query set to the user-provided project name.
234
235
  ```
235
236
 
236
- Run one queued UI job:
237
+ Claim one queued UI job for execution by the current agent:
237
238
 
238
239
  ```text
239
- Call launchchair_run_once.
240
+ Call launchchair_next_step with projectId.
240
241
  ```
241
242
 
242
- Run one queued UI job from CLI:
243
+ Run one queued UI job through an explicitly requested local runner:
243
244
 
244
245
  ```bash
245
246
  launchchair run-once
246
247
  ```
247
248
 
249
+ Every MCP tool is also accessible through `launchchair call TOOL --json '{...}'` or `--input FILE`. Use `launchchair tools --json` for schemas. Tool results are JSON, tool errors exit nonzero, and token secrets remain in private files. Prefer host execution for agents with connected GitHub/Vercel/Supabase tools.
250
+
248
251
  Select a generated wedge:
249
252
 
250
253
  ```text
@@ -259,3 +262,15 @@ Call launchchair_select_choice with domain="wedge" and optionTitle or optionId.
259
262
  - Do not run production SQL without explicit user authorization and a known project ref.
260
263
  - Do not mark QA complete without matching evidence. For Feature projects, produce that evidence by running the repository-native or browser checks whenever possible.
261
264
  - Do not search the whole filesystem for LaunchChair source when the public docs and MCP tools are available.
265
+
266
+ ## MCP login and host execution
267
+
268
+ The MCP can start without a token. Call `launchchair_login`, show the returned LaunchChair approval URL, and call `launchchair_login_status` after approval. Respect its poll interval. Tokens are stored privately in the local config and never returned to the conversation. Existing tokens need a fresh owner-approved login for `tokens.manage`. Use `launchchair_create_token` to save a scoped credential file, `launchchair_list_tokens` for redacted metadata, and `launchchair_revoke_token`/`launchchair_logout` for cleanup.
269
+
270
+ Prefer `launchchair_next_step`, execute the claimed prompt in the current agent with its already-connected plugins, call `launchchair_complete_run`, and repeat. Do not spawn another model or local CLI unless explicitly requested (`executionMode=local_runner`). A waiting run is already claimed; poll it instead of executing it twice. Inspect forms/checklists with `launchchair_get_bucket`, save allowed patches with `launchchair_update_bucket`, and supply factual `executionEvidence` for setup and verification. Use `launchchair_get_starter` after validation to read the starter files and the host's GitHub/Vercel/Supabase tools to create and deploy the user's app. Bind the verified clone with `launchchair_bind_repository`. Use `stopPhaseKey=launch_sales` for the complete Product workflow.
271
+
272
+ Preserve a supplied idea and specified requirements, even with low validation scores. `ideaIntent=preserve` is the default. Report risks and continue the requested work when no score gate was requested; never pivot merely to improve a score. Choose a generated direction consistent with the supplied idea. Only use `ideaIntent=explore` for explicit discovery or pivot requests.
273
+
274
+ For “do not build until VERY high validation,” create candidates with `validationGate=very_high` (pain >=85, opportunity >=85, saturation <=30), validate through `market_validation`, and compare with `launchchair_compare_projects`. The gate is enforced on LaunchChair build/setup actions and cannot be weakened through generic mutations. Do not provision external resources until it passes. If no candidate qualifies, report the unmet thresholds honestly. Scores are research estimates, not a guarantee of market demand. Do not fabricate evidence or replace validation scores with direction-option scores.
275
+
276
+ Research sources, competitor notes, Pexels assets, logo upload, form/checklist data, prompts, build cards, run management, and markdown exports are available as MCP tools. Tool descriptions distinguish data writes from actual external execution. Existing user authorization carries forward; ask only at a genuine missing authorization or information boundary.
package/README.md CHANGED
@@ -4,24 +4,19 @@ LaunchChair Agent packages the LaunchChair CLI, MCP bridge, local bridge, and ru
4
4
 
5
5
  Use it when you want an agent to create or discover LaunchChair projects, run product phases, continue project loops, pick up `Run with agent` jobs from LaunchChair, or expose LaunchChair tools through MCP.
6
6
 
7
- ## Install Locally For Testing
8
-
9
- From this package directory:
7
+ ## Install or update
10
8
 
11
9
  ```bash
12
- npm pack
13
- npm install -g ./launchchair-agent-*.tgz
14
- launchchair help
10
+ npm install -g @launchchair/agent@latest
11
+ launchchair --version
15
12
  launchchair doctor
16
13
  ```
17
14
 
18
- After publishing:
15
+ Version 0.5.0 includes the expanded MCP, browser login, private token management, host-agent execution, idea preservation, and optional validation gates. Existing installations must update the package and restart their MCP connection to load the new tools.
19
16
 
20
- ```bash
21
- npm install -g @launchchair/agent
22
- launchchair help
23
- launchchair doctor
24
- ```
17
+ After updating, run `launchchair setup codex --skip-login` or `launchchair setup claude --skip-login` to refresh the installed path and verify tool discovery. Then restart/reload that client. Your saved account login and project bindings remain in `~/.launchchair/config.json`. Older tokens still work within their existing scopes; run `launchchair login` again with workspace-owner approval if you need the new `tokens.manage` scope.
18
+
19
+ For local release testing, run `npm pack ./packages/launchchair-agent --pack-destination /tmp` from the repository root and install the resulting tarball into an isolated npm prefix before publishing.
25
20
 
26
21
  ## Login
27
22
 
@@ -49,7 +44,7 @@ Tokens are masked in CLI output. Do not paste real tokens into public docs, scre
49
44
 
50
45
  ## Guided Setup
51
46
 
52
- Use `setup` as the easiest path. It starts browser login when no token is saved yet, writes the global MCP config for your agent, creates isolated per-project workspaces, and then proves the server can complete MCP `initialize` and `tools/list` before reporting success.
47
+ Use `setup` as the easiest path. It starts browser login when no token is saved yet, writes the global MCP config for your agent, creates isolated per-project workspaces, and then verifies MCP discovery and an authenticated status call before reporting authenticated success. Unauthenticated discovery is reported separately as login required.
53
48
 
54
49
  Codex Desktop:
55
50
 
@@ -118,6 +113,29 @@ launchchair mcp-config claude
118
113
  launchchair install-agent-md --cwd /absolute/path/to/repo
119
114
  ```
120
115
 
116
+ ## Use any MCP tool from the terminal
117
+
118
+ `tools` discovers the same catalog as your MCP connection (47 tools when authenticated). `call` uses the bundled MCP implementation, including its login, permission checks, private credential storage, and host-execution defaults. Tool errors return a nonzero exit status and preserve details such as an upgrade URL.
119
+
120
+ ```bash
121
+ launchchair tools --json
122
+ launchchair call status
123
+ launchchair call next_step --json '{"projectId":"PROJECT_UUID"}'
124
+ launchchair call complete_run --input completed-run.json
125
+ launchchair call list_tokens
126
+ launchchair call create_token --json '{"name":"Build agent","scopes":["spec.read"]}'
127
+ ```
128
+
129
+ Tool names work with or without the `launchchair_` prefix. Use `--input -` to read JSON arguments from stdin. `next_step` returns a claimed prompt for your current agent to execute with its connected plugins; it does not start another agent. `complete_run` submits the actual result and execution evidence. Token creation returns a private credential-file path, never the secret in terminal output.
130
+
131
+ Project creation preserves a supplied idea by default, even when validation is low. Only request exploration and a score gate when that matches the user's intent:
132
+
133
+ ```bash
134
+ launchchair create --name "Groomer candidate" --one-liner "Reduce missed appointments" --idea-intent explore --validation-gate very_high --save-project
135
+ ```
136
+
137
+ The gate requires pain >=85, opportunity >=85, and saturation <=30 before build/setup. For custom thresholds or the full Feature input schema, use `launchchair call create_project` or `launchchair call create_feature_project` with JSON arguments.
138
+
121
139
  Feature projects can be created and planned from the CLI for every authenticated LaunchChair workspace:
122
140
 
123
141
  ```bash
@@ -158,7 +176,7 @@ launchchair mcp-config claude
158
176
 
159
177
  The recommended config launches the MCP server through the exact Node executable and CLI file path generated by `launchchair setup` or `launchchair mcp-config`. It also pins `LAUNCHCHAIR_CONFIG_DIR` to the exact directory used by `launchchair login`, so Codex and Claude users do not need to paste tokens into MCP config after browser login—even when the desktop app starts with a different home or shell environment. The server uses standard newline-delimited JSON-RPC over stdio, as required by MCP clients.
160
178
 
161
- Use `LAUNCHCHAIR_AGENT_TOKEN` only as a manual fallback when you skip `launchchair login`, need a per-client token override, or your desktop app cannot read the saved CLI config. If you use a desktop custom MCP form, run `launchchair mcp-config codex --ui-fields` or `launchchair mcp-config claude --ui-fields` and paste the printed command, each argument, env vars, and working directory exactly as shown.
179
+ Use `LAUNCHCHAIR_AGENT_TOKEN` as a manual fallback when you skip `launchchair login` or your desktop app cannot read the saved config. A saved browser login takes precedence over stale environment credentials. Logout disables both CLI and MCP authentication until another login. For a separate per-client account, use a separate `LAUNCHCHAIR_CONFIG_DIR`. If you use a desktop custom MCP form, run `launchchair mcp-config codex --ui-fields` or `launchchair mcp-config claude --ui-fields` and paste the printed command, each argument, env vars, and working directory exactly as shown.
162
180
 
163
181
  ## Local Bridge
164
182
 
@@ -207,3 +225,15 @@ This package includes `AGENTS.md`. Fresh Codex, Claude, and Hermes chats should
207
225
  - MCP docs: https://www.launchchair.io/docs/mcp
208
226
  - Agent API docs: https://www.launchchair.io/docs/agent-api
209
227
  - Machine-readable catalog: https://www.launchchair.io/api/agent/v1/docs
228
+
229
+ ## MCP login and host execution
230
+
231
+ The MCP can start without a token. Call `launchchair_login`, show the returned LaunchChair approval URL, and call `launchchair_login_status` after approval. Respect its poll interval. Tokens are stored privately in the local config and never returned to the conversation. Existing tokens need a fresh owner-approved login for `tokens.manage`. Use `launchchair_create_token` to save a scoped credential file, `launchchair_list_tokens` for redacted metadata, and `launchchair_revoke_token`/`launchchair_logout` for cleanup.
232
+
233
+ Prefer `launchchair_next_step`, execute the claimed prompt in the current agent with its already-connected plugins, call `launchchair_complete_run`, and repeat. Do not spawn another model or local CLI unless explicitly requested (`executionMode=local_runner`). A waiting run is already claimed; poll it instead of executing it twice. Inspect forms/checklists with `launchchair_get_bucket`, save allowed patches with `launchchair_update_bucket`, and supply factual `executionEvidence` for setup and verification. Use `launchchair_get_starter` after validation to read the starter files and the host's GitHub/Vercel/Supabase tools to create and deploy the user's app. Bind the verified clone with `launchchair_bind_repository`. Use `stopPhaseKey=launch_sales` for the complete Product workflow.
234
+
235
+ Preserve a supplied idea and specified requirements, even with low validation scores. `ideaIntent=preserve` is the default. Report risks and continue the requested work when no score gate was requested; never pivot merely to improve a score. Choose a generated direction consistent with the supplied idea. Only use `ideaIntent=explore` for explicit discovery or pivot requests.
236
+
237
+ For “do not build until VERY high validation,” create candidates with `validationGate=very_high` (pain >=85, opportunity >=85, saturation <=30), validate through `market_validation`, and compare with `launchchair_compare_projects`. The gate is enforced on LaunchChair build/setup actions and cannot be weakened through generic mutations. Do not provision external resources until it passes. If no candidate qualifies, report the unmet thresholds honestly. Scores are research estimates, not a guarantee of market demand. Do not fabricate evidence or replace validation scores with direction-option scores.
238
+
239
+ Research sources, competitor notes, Pexels assets, logo upload, form/checklist data, prompts, build cards, run management, and markdown exports are available as MCP tools. Tool descriptions distinguish data writes from actual external execution. Existing user authorization carries forward; ask only at a genuine missing authorization or information boundary.
@@ -20,7 +20,7 @@ import {
20
20
  unbindProjectWorkspace,
21
21
  } from "./project-workspace.mjs";
22
22
 
23
- const VERSION = "0.4.12";
23
+ const VERSION = "0.5.0";
24
24
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
25
25
  const DEFAULT_BASE_URL = "https://www.launchchair.io";
26
26
  const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
@@ -109,10 +109,16 @@ function configValue(key, envNames = [], fallback = "") {
109
109
  }
110
110
 
111
111
  function token() {
112
+ const config = loadConfig();
113
+ if (config.mcpLoggedOut === true) return "";
114
+ if (config.mcpPreferSavedToken === true && config.agentToken) return config.agentToken;
112
115
  return configValue("agentToken", ["LAUNCHCHAIR_AGENT_TOKEN", "LAUNCHCHAIR_API_KEY"]);
113
116
  }
114
117
 
115
118
  function tokenSource() {
119
+ const config = loadConfig();
120
+ if (config.mcpLoggedOut === true) return "logged out";
121
+ if (config.mcpPreferSavedToken === true && config.agentToken) return CONFIG_PATH;
116
122
  if (env("LAUNCHCHAIR_AGENT_TOKEN")) return "LAUNCHCHAIR_AGENT_TOKEN";
117
123
  if (env("LAUNCHCHAIR_API_KEY")) return "LAUNCHCHAIR_API_KEY";
118
124
  if (loadConfig().agentToken) return CONFIG_PATH;
@@ -171,6 +177,7 @@ Use MCP-only when you want agent tools inside Codex/Claude/Hermes.
171
177
  Use CLI + MCP when you also want quick terminal commands, diagnostics, and bridge control.
172
178
 
173
179
  Commands:
180
+ --version Show the installed package version
174
181
  doctor Check token, docs, API reachability, and local bridge files
175
182
  login [--token TOKEN] Open browser login and save a LaunchChair Agent API token locally
176
183
  logout Remove the saved local token
@@ -183,6 +190,9 @@ Commands:
183
190
  run-once Claim and run one queued LaunchChair job through the local bridge
184
191
  bridge Start the long-running local bridge for Run with Agent
185
192
  mcp-server Run the stdio MCP server using saved CLI config
193
+ tools [--json] List the available MCP tools and their input schemas
194
+ call TOOL [--json JSON|--input FILE]
195
+ Call any MCP tool; JSON output, nonzero exit on tool errors
186
196
  setup codex|claude|claude-desktop|claude-code|terminal [options]
187
197
  Guided global CLI/MCP setup with isolated project workspaces
188
198
  bind --project-id ID --cwd PATH Bind one LaunchChair project to a verified local Git clone
@@ -202,6 +212,8 @@ Create options:
202
212
  --start-loop
203
213
  --save-project Save the created project id as the CLI default
204
214
  --idempotency-key KEY
215
+ --idea-intent preserve|explore Default: preserve the supplied idea, even with low scores
216
+ --validation-gate very_high Require pain >=85, opportunity >=85, saturation <=30 before build
205
217
 
206
218
  Feature create options:
207
219
  --project-kind feature
@@ -245,7 +257,7 @@ Login options:
245
257
  --timeout SECONDS Default: 600
246
258
 
247
259
  Env:
248
- LAUNCHCHAIR_AGENT_TOKEN Optional full lc_at token override
260
+ LAUNCHCHAIR_AGENT_TOKEN Optional token fallback when no saved login takes precedence
249
261
  LAUNCHCHAIR_BASE_URL Default: https://www.launchchair.io
250
262
  LAUNCHCHAIR_PROJECT_ID Optional default project id
251
263
  LC_AGENT_CODEX_CWD Legacy candidate only; verified per project before use
@@ -255,6 +267,11 @@ MCP config options:
255
267
  --ui-fields Print fields for Codex Desktop's custom MCP form
256
268
  --copy-config Copy generated output to clipboard on macOS
257
269
 
270
+ Tool call options:
271
+ --json JSON Tool arguments as a JSON object; defaults to {}
272
+ --input FILE Read tool arguments from a JSON file; use - for stdin
273
+ --timeout SECONDS Request timeout. Default: 60, max: 3600
274
+
258
275
  Config:
259
276
  ${CONFIG_PATH}
260
277
  `);
@@ -308,7 +325,13 @@ async function api(pathname, options = {}) {
308
325
  if (!response.ok) {
309
326
  const retryAfter = response.headers.get("retry-after");
310
327
  const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
311
- throw new Error(`${body?.error ?? response.statusText}${suffix}`);
328
+ const next = body?.upgradeUrl
329
+ ? ` Upgrade: ${body.upgradeUrl}`
330
+ : response.status === 401 ? " Run launchchair login to reconnect." : "";
331
+ const error = new Error(`${body?.error ?? response.statusText}${suffix}${next}`);
332
+ error.status = response.status;
333
+ error.retryAfterSeconds = Number(retryAfter) || 10;
334
+ throw error;
312
335
  }
313
336
  return body;
314
337
  }
@@ -331,7 +354,10 @@ async function publicApi(pathname, options = {}) {
331
354
  if (!response.ok) {
332
355
  const retryAfter = response.headers.get("retry-after");
333
356
  const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
334
- throw new Error(`${body?.error ?? response.statusText}${suffix}`);
357
+ const error = new Error(`${body?.error ?? response.statusText}${suffix}`);
358
+ error.status = response.status;
359
+ error.retryAfterSeconds = Number(retryAfter) || 10;
360
+ throw error;
335
361
  }
336
362
  return body;
337
363
  }
@@ -391,6 +417,8 @@ async function login(args) {
391
417
  }
392
418
  saveConfig({
393
419
  agentToken: incomingToken,
420
+ mcpLoggedOut: false,
421
+ mcpPreferSavedToken: true,
394
422
  baseUrl: incomingBaseUrl ?? baseUrl(),
395
423
  projectId: incomingProjectId || defaultProjectId() || undefined,
396
424
  });
@@ -434,7 +462,7 @@ async function login(args) {
434
462
 
435
463
  const timeoutSeconds =
436
464
  typeof args.timeout === "string" && Number.isFinite(Number(args.timeout)) ? Math.max(10, Number(args.timeout)) : Number(start.expiresIn ?? 600);
437
- const intervalSeconds = Math.max(2, Number(start.interval ?? 2));
465
+ let intervalSeconds = Math.max(5, Number(start.interval ?? 5));
438
466
  const startedAt = Date.now();
439
467
  print("");
440
468
  print("Waiting for approval. Leave this terminal open.");
@@ -449,10 +477,12 @@ async function login(args) {
449
477
  });
450
478
  } catch (error) {
451
479
  const message = error instanceof Error ? error.message : String(error);
480
+ if (error.status === 429) { intervalSeconds = Math.max(5, error.retryAfterSeconds); continue; }
452
481
  if (/pending/i.test(message)) continue;
453
482
  throw error;
454
483
  }
455
484
  if (poll?.status === "pending") {
485
+ intervalSeconds = Math.max(5, Number(poll.interval) || intervalSeconds);
456
486
  write(paint(".", "dim"));
457
487
  continue;
458
488
  }
@@ -460,6 +490,8 @@ async function login(args) {
460
490
  print("");
461
491
  saveConfig({
462
492
  agentToken: poll.token,
493
+ mcpLoggedOut: false,
494
+ mcpPreferSavedToken: true,
463
495
  baseUrl: baseUrl(),
464
496
  projectId: incomingProjectId || defaultProjectId() || undefined,
465
497
  });
@@ -478,11 +510,12 @@ async function login(args) {
478
510
  }
479
511
 
480
512
  function logout() {
481
- deleteConfigKeys(["agentToken"]);
513
+ deleteConfigKeys(["agentToken", "pendingMcpLogin"]);
514
+ saveConfig({ mcpLoggedOut: true });
482
515
  title("CLI logout");
483
516
  ok(`Removed saved token from ${CONFIG_PATH}`);
484
517
  if (env("LAUNCHCHAIR_AGENT_TOKEN") || env("LAUNCHCHAIR_API_KEY")) {
485
- warn("An environment token is still set, so CLI commands may remain authenticated in this shell.");
518
+ warn("An environment token is still set; it remains ignored until you log in again.");
486
519
  }
487
520
  }
488
521
 
@@ -606,6 +639,14 @@ async function createProject(args) {
606
639
  throw new Error("--project-kind must be product or feature.");
607
640
  }
608
641
  const featureProject = projectKind === "feature";
642
+ const ideaIntent = args["idea-intent"] ?? "preserve";
643
+ if (!["preserve", "explore"].includes(ideaIntent)) {
644
+ throw new Error("--idea-intent must be preserve or explore.");
645
+ }
646
+ const validationGate = args["validation-gate"];
647
+ if (validationGate !== undefined && validationGate !== "very_high") {
648
+ throw new Error("--validation-gate must be very_high. For custom thresholds, use launchchair call create_project --input FILE.");
649
+ }
609
650
  const featureDescription = String(args["feature-description"] ?? args["one-liner"] ?? "").trim();
610
651
  const featureProblem = String(args.problem ?? "").trim();
611
652
  if (featureProject && (!featureDescription || !featureProblem)) {
@@ -664,7 +705,7 @@ async function createProject(args) {
664
705
  const body = await api("/api/agent/v1/projects", {
665
706
  method: "POST",
666
707
  headers,
667
- body: JSON.stringify(requestBody),
708
+ body: JSON.stringify({ ...requestBody, ideaIntent, validationGate }),
668
709
  });
669
710
  const projectId = body?.project?.id ?? body?.projectId ?? "";
670
711
  title("Project created");
@@ -876,6 +917,100 @@ function spawnMcpServer(extraArgs = []) {
876
917
  });
877
918
  }
878
919
 
920
+ // Use the bundled MCP so terminal calls share its login, scope checks, and host workflow.
921
+ async function requestMcp(method, params, timeoutMs = 60_000) {
922
+ const child = spawn(process.execPath, [path.join(SCRIPT_DIR, "mcp-bridge-server.mjs")], {
923
+ env: buildBridgeEnv(),
924
+ stdio: ["pipe", "pipe", "pipe"],
925
+ });
926
+ let buffer = "";
927
+ let serial = 0;
928
+ const pending = new Map();
929
+ const failPending = (error) => {
930
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
931
+ pending.clear();
932
+ };
933
+ child.on("error", failPending);
934
+ child.on("exit", (code) => failPending(new Error(`MCP server exited before replying (code ${code ?? "unknown"}).`)));
935
+ child.stdin.on("error", failPending);
936
+ child.stderr.on("data", (chunk) => process.stderr.write(chunk));
937
+ child.stdout.setEncoding("utf8");
938
+ child.stdout.on("data", (chunk) => {
939
+ buffer += chunk;
940
+ let newline;
941
+ while ((newline = buffer.indexOf("\n")) !== -1) {
942
+ const line = buffer.slice(0, newline).trim();
943
+ buffer = buffer.slice(newline + 1);
944
+ if (!line) continue;
945
+ let message;
946
+ try { message = JSON.parse(line); }
947
+ catch { failPending(new Error("MCP server returned invalid JSON.")); return; }
948
+ const entry = pending.get(message.id);
949
+ if (!entry) continue;
950
+ pending.delete(message.id);
951
+ clearTimeout(entry.timer);
952
+ if (message.error) entry.reject(new Error(message.error.message || "MCP request failed."));
953
+ else entry.resolve(message.result);
954
+ }
955
+ });
956
+ const rpc = (rpcMethod, rpcParams) => new Promise((resolve, reject) => {
957
+ const id = ++serial;
958
+ const timer = setTimeout(() => {
959
+ pending.delete(id);
960
+ reject(new Error("MCP request timed out. Check the project's run status before retrying a write."));
961
+ }, timeoutMs);
962
+ pending.set(id, { resolve, reject, timer });
963
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method: rpcMethod, params: rpcParams })}\n`);
964
+ });
965
+ try {
966
+ await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "launchchair-cli", version: VERSION } });
967
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
968
+ return await rpc(method, params);
969
+ } finally {
970
+ child.stdin.end();
971
+ child.kill();
972
+ failPending(new Error("MCP client closed."));
973
+ }
974
+ }
975
+
976
+ async function listTools(args) {
977
+ const result = await requestMcp("tools/list", {});
978
+ if (args.json === true) return print(JSON.stringify(result, null, 2));
979
+ title("MCP tools");
980
+ for (const tool of result.tools ?? []) print(`${tool.name}\n ${tool.description}`);
981
+ print("\nUse launchchair tools --json for input schemas, then launchchair call TOOL --json '{...}'.");
982
+ }
983
+
984
+ async function callTool(args) {
985
+ const requestedName = args._[0];
986
+ if (!requestedName) throw new Error("call requires a tool name. Run launchchair tools to list them.");
987
+ if (args.json !== undefined && args.input !== undefined) throw new Error("Use either --json or --input, not both.");
988
+ let input = "{}";
989
+ if (args.json !== undefined) {
990
+ if (typeof args.json !== "string") throw new Error("--json requires a JSON object.");
991
+ input = args.json;
992
+ } else if (args.input !== undefined) {
993
+ if (typeof args.input !== "string") throw new Error("--input requires a file path or - for stdin.");
994
+ input = readFileSync(args.input === "-" ? 0 : path.resolve(args.input), "utf8");
995
+ }
996
+ let toolArgs;
997
+ try { toolArgs = JSON.parse(input); }
998
+ catch { throw new Error("Tool arguments must be valid JSON."); }
999
+ if (!toolArgs || typeof toolArgs !== "object" || Array.isArray(toolArgs)) throw new Error("Tool arguments must be a JSON object.");
1000
+ const timeoutSeconds = args.timeout === undefined ? 60 : Number(args.timeout);
1001
+ if (typeof args.timeout === "boolean" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3600) {
1002
+ throw new Error("--timeout must be between 1 and 3600 seconds.");
1003
+ }
1004
+ const result = await requestMcp("tools/call", {
1005
+ name: requestedName.startsWith("launchchair_") ? requestedName : `launchchair_${requestedName}`,
1006
+ arguments: toolArgs,
1007
+ }, timeoutSeconds * 1000);
1008
+ // LaunchChair tools return one JSON text block. Keep other MCP content intact.
1009
+ print(result.content?.length === 1 && result.content[0].type === "text"
1010
+ ? result.content[0].text : JSON.stringify(result, null, 2));
1011
+ if (result.isError) process.exitCode = 1;
1012
+ }
1013
+
879
1014
  function normalizedMcpTarget(target) {
880
1015
  return targetDetails(target || "codex").mcpTarget || "codex";
881
1016
  }
@@ -885,13 +1020,11 @@ function tomlString(value) {
885
1020
  }
886
1021
 
887
1022
  function mcpEnvForTarget(target, options = {}) {
888
- const includeTokenPlaceholder = options.includeTokenPlaceholder !== false;
889
1023
  const details = targetDetails(target || "codex");
890
1024
  const projectId = defaultProjectId();
891
1025
  const envBlock = {
892
1026
  LAUNCHCHAIR_BASE_URL: baseUrl(),
893
1027
  LAUNCHCHAIR_CONFIG_DIR: CONFIG_DIR,
894
- ...(token() || !includeTokenPlaceholder ? {} : { LAUNCHCHAIR_AGENT_TOKEN: "<optional full lc_at token when launchchair login was not used>" }),
895
1028
  ...(projectId ? { LAUNCHCHAIR_PROJECT_ID: projectId } : {}),
896
1029
  LC_AGENT_ROUTER_GPT_TARGET: details.routerTarget,
897
1030
  LC_AGENT_ROUTER_CODEX_TARGET: details.routerTarget,
@@ -916,6 +1049,7 @@ function verifyMcpServer(target, timeoutMs = 20_000) {
916
1049
  stdio: ["pipe", "pipe", "pipe"],
917
1050
  });
918
1051
  let settled = false;
1052
+ let discoveredTools = [];
919
1053
  let stdout = "";
920
1054
  let stderr = "";
921
1055
 
@@ -966,13 +1100,29 @@ function verifyMcpServer(target, timeoutMs = 20_000) {
966
1100
  const toolNames = Array.isArray(response?.result?.tools)
967
1101
  ? response.result.tools.map((tool) => String(tool?.name || "")).filter(Boolean)
968
1102
  : [];
969
- const requiredTools = ["launchchair_status", "launchchair_create_project", "launchchair_list_projects"];
1103
+ const requiredTools = [
1104
+ "launchchair_status", "launchchair_create_project", "launchchair_list_projects",
1105
+ "launchchair_login", "launchchair_login_status", "launchchair_create_token",
1106
+ "launchchair_next_step", "launchchair_complete_run", "launchchair_get_validation_gate",
1107
+ "launchchair_update_bucket", "launchchair_get_starter", "launchchair_export_mvp_spec",
1108
+ ];
970
1109
  const missingTools = requiredTools.filter((name) => !toolNames.includes(name));
971
1110
  if (missingTools.length) {
972
1111
  finish(new Error(`MCP verification did not discover required tools: ${missingTools.join(", ")}`));
973
1112
  return;
974
1113
  }
975
- finish(null, toolNames);
1114
+ discoveredTools = toolNames;
1115
+ if (!token()) { finish(null, toolNames); continue; }
1116
+ send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "launchchair_status", arguments: {} } });
1117
+ }
1118
+ if (response?.id === 3) {
1119
+ let status = {};
1120
+ try { status = JSON.parse(response.result?.content?.[0]?.text || "{}"); } catch {}
1121
+ if (response.error || response.result?.isError || status.authenticated !== true) {
1122
+ finish(new Error(`MCP authentication verification failed: ${status.error || response.error?.message || "not authenticated"}`));
1123
+ return;
1124
+ }
1125
+ finish(null, discoveredTools);
976
1126
  }
977
1127
  }
978
1128
  });
@@ -1286,7 +1436,10 @@ function installLaunchChairSkill(args) {
1286
1436
  "# LaunchChair workflow",
1287
1437
  "",
1288
1438
  "Use the installed LaunchChair MCP tools before web search or local repo discovery for LaunchChair project work.",
1289
- "Call launchchair_status if capability context is unclear.",
1439
+ "Call launchchair_status if capability context is unclear. If login is required, use launchchair_login and launchchair_login_status; never print token secrets.",
1440
+ "Prefer launchchair_next_step and execute its claimed prompt in this host agent using existing plugins, then complete_run. Use local runners only when explicitly requested.",
1441
+ "Preserve supplied ideas and requirements even when validation is low. ideaIntent=preserve is the default; exploration and pivots require an explicit user request.",
1442
+ "Only impose a validation gate when requested. VERY high means validationGate=very_high (pain>=85, opportunity>=85, saturation<=30). Never fabricate evidence or weaken the gate.",
1290
1443
  "For a new product, use launchchair_create_project.",
1291
1444
  "For a feature in an existing product, use launchchair_create_feature_project. Never substitute a Product project if that tool is unavailable; report the setup or authentication issue instead.",
1292
1445
  "Use planToBuildBoard=true for planning through the Feature build board.",
@@ -1326,6 +1479,8 @@ async function setup(args) {
1326
1479
  }
1327
1480
  saveConfig({
1328
1481
  agentToken: incomingToken,
1482
+ mcpLoggedOut: false,
1483
+ mcpPreferSavedToken: true,
1329
1484
  baseUrl: incomingBaseUrl ?? baseUrl(),
1330
1485
  projectId: incomingProjectId || defaultProjectId() || undefined,
1331
1486
  });
@@ -1412,11 +1567,11 @@ async function setup(args) {
1412
1567
  }
1413
1568
  }
1414
1569
 
1415
- if (!details.terminalOnly && token() && args["skip-mcp-check"] !== true) {
1570
+ if (!details.terminalOnly && args["skip-mcp-check"] !== true) {
1416
1571
  const toolNames = await verifyMcpServer(details.mcpTarget);
1417
1572
  print("");
1418
- title("MCP verification passed");
1419
- ok(`Initialize and tools/list succeeded; discovered ${toolNames.length} LaunchChair tools.`);
1573
+ title(token() ? "MCP verification passed" : "MCP discovery verified — login required");
1574
+ ok(`Discovered ${toolNames.length} LaunchChair tools.${token() ? " Authenticated status verified." : " Call launchchair_login in your agent."}`);
1420
1575
  } else if (!details.terminalOnly && !token()) {
1421
1576
  warn("MCP verification was skipped because no LaunchChair token is configured.");
1422
1577
  } else if (!details.terminalOnly && args["skip-mcp-check"] === true) {
@@ -1455,7 +1610,8 @@ async function setup(args) {
1455
1610
  print(`2. Restart or reload ${details.label} so the MCP server is exposed to fresh chats.`);
1456
1611
  }
1457
1612
  print("Use the generated command and argument fields exactly as printed; setup resolves the local Node and CLI paths for this machine.");
1458
- print("Keep this bridge running when you want LaunchChair's Run with agent button to work:");
1613
+ print("Your agent can use launchchair_next_step and launchchair_complete_run with its connected plugins.");
1614
+ print("For unattended local-runner jobs queued from LaunchChair's Run with agent button, optionally run:");
1459
1615
  print(` ${paint("launchchair bridge", "cyan")}`);
1460
1616
  print("Start a fresh agent chat. The agent should discover the LaunchChair MCP tools automatically.");
1461
1617
  }
@@ -1479,6 +1635,7 @@ async function main() {
1479
1635
  const [command = "help", ...rest] = process.argv.slice(2);
1480
1636
  const args = parseArgs(rest);
1481
1637
  if (command === "help" || command === "--help" || command === "-h") return help();
1638
+ if (command === "--version" || command === "-v" || command === "version") return print(VERSION);
1482
1639
  if (command === "doctor") return doctor();
1483
1640
  if (command === "login") return login(args);
1484
1641
  if (command === "logout") return logout();
@@ -1490,6 +1647,8 @@ async function main() {
1490
1647
  if (command === "run-once") return spawnBridge(["--once", "--verbose"]);
1491
1648
  if (command === "bridge") return spawnBridge(["--verbose"]);
1492
1649
  if (command === "mcp-server") return spawnMcpServer();
1650
+ if (command === "tools") return listTools(args);
1651
+ if (command === "call") return callTool(args);
1493
1652
  if (command === "setup") return setup(args);
1494
1653
  if (command === "bind") return bindProject(args);
1495
1654
  if (command === "unbind") return unbindProject(args);
package/local-bridge.mjs CHANGED
@@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
  import { resolveProjectWorkspace } from "./project-workspace.mjs";
19
19
  import { buildRunnerCommand, parseRunnerCommand } from "./runner-command.mjs";
20
20
 
21
- const VERSION = "0.4.12";
21
+ const VERSION = "0.5.0";
22
22
  const CLIENT_TYPES = ["codex", "chatgpt", "claude", "claude_code", "other"];
23
23
  const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
24
24
  const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
@@ -402,6 +402,7 @@ async function executeRunner(run) {
402
402
  stdio: ["pipe", "pipe", "pipe"],
403
403
  env: {
404
404
  ...process.env,
405
+ ...(resolved.workspace.source === "isolated" && run?.runnerWorkspace?.requiresRepository !== true ? { LC_AGENT_CODEX_SKIP_GIT_CHECK: "1" } : {}),
405
406
  LC_AGENT_RUN_ID: String(run.id ?? ""),
406
407
  LC_AGENT_PROJECT_ID: String(run.projectId ?? ""),
407
408
  LC_AGENT_PHASE_KEY: String(run.phaseKey ?? ""),
@@ -19,8 +19,9 @@ import process from "node:process";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { resolveProjectWorkspace } from "./project-workspace.mjs";
21
21
  import { buildRunnerCommand, parseRunnerCommand } from "./runner-command.mjs";
22
+ import { createMcpCapabilities, EXTRA_MCP_TOOLS, MCP_WORKFLOW_INSTRUCTIONS, VALIDATION_GATE_SCHEMA } from "./mcp-capabilities.mjs";
22
23
 
23
- const VERSION = "0.4.12";
24
+ const VERSION = "0.5.0";
24
25
  const SERVER_NAME = "launchchair-agent-mcp";
25
26
  const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
26
27
  const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
@@ -108,7 +109,7 @@ function builtInRunnerCommand(target) {
108
109
  }
109
110
 
110
111
  const config = {
111
- baseUrl: env("LAUNCHCHAIR_BASE_URL", savedConfigValue("baseUrl", "http://localhost:3000")).replace(/\/+$/, ""),
112
+ baseUrl: env("LAUNCHCHAIR_BASE_URL", savedConfigValue("baseUrl", "https://www.launchchair.io")).replace(/\/+$/, ""),
112
113
  token: env("LAUNCHCHAIR_AGENT_TOKEN", env("LAUNCHCHAIR_API_KEY", savedConfigValue("agentToken"))),
113
114
  projectId: env("LAUNCHCHAIR_PROJECT_ID", savedConfigValue("projectId")) || null,
114
115
  clientName: env("LC_AGENT_CLIENT_NAME", "MCP Bridge"),
@@ -135,70 +136,52 @@ const config = {
135
136
  },
136
137
  };
137
138
 
138
- if (!config.token) {
139
- // eslint-disable-next-line no-console
140
- console.error(
141
- `Missing LaunchChair Agent API token. Run "launchchair login" or set LAUNCHCHAIR_AGENT_TOKEN. Checked ${CONFIG_PATH}.`,
142
- );
143
- process.exit(1);
144
- }
145
-
146
- if (
147
- !config.mockResponse &&
148
- !config.runnerCommands.generic &&
149
- !config.runnerCommands.gpt &&
150
- !config.runnerCommands.codex &&
151
- !config.runnerCommands.chatgpt &&
152
- !config.runnerCommands.claude &&
153
- !config.runnerCommands.codexTarget &&
154
- !config.runnerCommands.claudeCode
155
- ) {
156
- // eslint-disable-next-line no-console
157
- console.error(
158
- "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.",
159
- );
160
- process.exit(1);
161
- }
139
+ let statusPromise = null;
140
+ let initialized = false;
141
+ const capabilities = createMcpCapabilities({ config, configPath: CONFIG_PATH, api, resolveProjectId, onAuthChange: () => { statusPromise = null; if (initialized) writeMessage({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }); } });
162
142
 
163
143
  async function api(path, { method = "GET", body } = {}) {
144
+ const token = capabilities.currentToken();
145
+ if (!token) {
146
+ const error = new Error("Login required. Call launchchair_login and complete browser approval.");
147
+ error.status = 401;
148
+ error.payload = { code: "login_required", nextTool: "launchchair_login" };
149
+ throw error;
150
+ }
164
151
  const response = await fetch(`${config.baseUrl}${path}`, {
165
152
  method,
166
153
  headers: {
167
- Authorization: `Bearer ${config.token}`,
154
+ Authorization: `Bearer ${token}`,
168
155
  "Content-Type": "application/json",
169
156
  },
170
157
  body: body === undefined ? undefined : JSON.stringify(body),
158
+ signal: AbortSignal.timeout(30_000),
171
159
  });
172
160
 
173
- let payload = null;
174
- try {
175
- payload = await response.json();
176
- } catch (_error) {
177
- payload = null;
178
- }
161
+ const raw = await response.text();
162
+ let payload;
163
+ try { payload = raw ? JSON.parse(raw) : null; } catch { payload = { content: raw, contentType: response.headers.get("content-type") }; }
179
164
 
180
165
  if (!response.ok) {
181
166
  const err = new Error(`HTTP ${response.status}: ${String(payload?.error ?? response.statusText ?? "Request failed")}`);
182
167
  err.status = response.status;
183
- err.payload = payload;
168
+ err.payload = response.status === 401 ? { ...payload, code: "login_required", nextTool: "launchchair_login" } : payload;
184
169
  err.retryAfterSeconds = Number.parseInt(response.headers.get("retry-after") ?? "", 10) || null;
185
170
  throw err;
186
171
  }
187
172
  return payload;
188
173
  }
189
174
 
190
- let statusPromise = null;
191
-
192
175
  function loadStatus() {
193
176
  if (!statusPromise) {
194
- statusPromise = api("/api/agent/v1").catch(() => null);
177
+ statusPromise = api("/api/agent/v1").catch(() => { statusPromise = null; return null; });
195
178
  }
196
179
  return statusPromise;
197
180
  }
198
181
 
199
182
  function serverInstructions(status) {
200
183
  const featureInstructions = String(status?.capabilities?.mcpServerInstructions ?? "").trim();
201
- return featureInstructions ? `${BASE_SERVER_INSTRUCTIONS} ${featureInstructions}` : BASE_SERVER_INSTRUCTIONS;
184
+ return `${BASE_SERVER_INSTRUCTIONS} ${featureInstructions} ${MCP_WORKFLOW_INSTRUCTIONS}`;
202
185
  }
203
186
 
204
187
  function targetForRun(run) {
@@ -291,6 +274,7 @@ async function executeRunner(run) {
291
274
  LC_AGENT_PROMPT_MODE: String(run.promptMode ?? ""),
292
275
  LC_AGENT_RUN_TYPE: String(run.runType ?? ""),
293
276
  [workspaceEnvName]: workspace.path,
277
+ ...(workspace.source === "isolated" && run?.runnerWorkspace?.requiresRepository !== true ? { LC_AGENT_CODEX_SKIP_GIT_CHECK: "1" } : {}),
294
278
  },
295
279
  });
296
280
  let stdout = "";
@@ -347,6 +331,7 @@ async function claimNextRun(args = {}) {
347
331
  method: "POST",
348
332
  body: {
349
333
  projectId,
334
+ ...(args.runId ? { runId: String(args.runId) } : {}),
350
335
  clientName: String(args.clientName ?? "").trim() || config.clientName,
351
336
  clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
352
337
  clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
@@ -600,15 +585,19 @@ async function continueProjectLoop(args = {}) {
600
585
  break;
601
586
  }
602
587
 
603
- const run = loop.run
604
- ? { ...loop.run, runnerWorkspace: loop.runnerWorkspace ?? runnerWorkspaceContext }
605
- : null;
606
- if (!run?.id || !run?.prompt) {
607
- final = loop;
588
+ const claimed = loop.run?.id ? await claimNextRun({ ...args, projectId, runId: loop.run.id }) : null;
589
+ if (!claimed) {
590
+ final = { ...loop, run: loop.run?.id ? { id: loop.run.id } : null, message: "Run already claimed; do not execute it again." };
591
+ break;
592
+ }
593
+ const run = { ...claimed, runnerWorkspace: claimed.runnerWorkspace ?? loop.runnerWorkspace ?? runnerWorkspaceContext };
594
+ let execution;
595
+ try { execution = await executeRunner(run); }
596
+ catch (error) {
597
+ await completeRun({ runId: run.id, status: "failed", error: error.message, resultRaw: error.stdout || error.stderr || "" });
598
+ final = { status: "stopped_after_failed_step", projectId, runId: run.id, message: error.message };
608
599
  break;
609
600
  }
610
-
611
- const execution = await executeRunner(run);
612
601
  const stdout = String(execution.stdout ?? "").trim();
613
602
  const stderr = String(execution.stderr ?? "").trim();
614
603
  const output = stdout || stderr;
@@ -695,6 +684,7 @@ async function createProject(args = {}) {
695
684
  delete createArgs.maxSteps;
696
685
  delete createArgs.fullLoop;
697
686
  delete createArgs.buildMaxSteps;
687
+ delete createArgs.executionMode;
698
688
  const body = {
699
689
  ...createArgs,
700
690
  name: String(args.name ?? args.projectName ?? "").trim(),
@@ -721,6 +711,8 @@ async function createProject(args = {}) {
721
711
  return payload;
722
712
  }
723
713
 
714
+ if (args.executionMode !== "local_runner") return { ...payload, next: { ...payload.next, tool: "launchchair_next_step", arguments: { projectId, stopPhaseKey: "build", completionTarget: fullLoop ? "phase_complete" : "build_board_ready" }, execution: "host", instructions: MCP_WORKFLOW_INSTRUCTIONS } };
715
+
724
716
  const planning = await continueProjectLoop({
725
717
  projectId,
726
718
  stopPhaseKey: "build",
@@ -999,8 +991,8 @@ function annotateTool(tool) {
999
991
  ...tool,
1000
992
  annotations: {
1001
993
  ...(tool.annotations ?? {}),
1002
- readOnlyHint: READ_ONLY_TOOLS.has(tool.name),
1003
- destructiveHint: false,
994
+ readOnlyHint: tool.annotations?.readOnlyHint ?? READ_ONLY_TOOLS.has(tool.name),
995
+ destructiveHint: tool.annotations?.destructiveHint ?? false,
1004
996
  openWorldHint: true,
1005
997
  },
1006
998
  };
@@ -1016,7 +1008,12 @@ function mergeToolOverrides(baseTools, overrides) {
1016
1008
  merged.push(override);
1017
1009
  }
1018
1010
  }
1019
- return merged.map(annotateTool);
1011
+ return merged.map((tool) => {
1012
+ if (["launchchair_create_project", "launchchair_create_feature_project"].includes(tool.name)) tool = { ...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, idempotencyKey: { type: "string" }, ideaIntent: { type: "string", enum: ["preserve", "explore"], default: "preserve" }, validationGate: VALIDATION_GATE_SCHEMA, executionMode: { type: "string", enum: ["host", "local_runner"], default: "host" } } } };
1013
+ if (tool.name === "launchchair_claim_next_run") tool = { ...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, runId: { type: "string" } } } };
1014
+ if (tool.name === "launchchair_continue_project") tool = { ...tool, description: "Advance one step in the current host agent by default. Only use executionMode=local_runner to spawn a separate local CLI.", inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, executionMode: { type: "string", enum: ["host", "local_runner"], default: "host" } } } };
1015
+ return annotateTool(tool);
1016
+ });
1020
1017
  }
1021
1018
 
1022
1019
  function writeMessage(obj) {
@@ -1059,7 +1056,7 @@ async function handleRpc(request) {
1059
1056
  writeResult(id, {
1060
1057
  protocolVersion: "2024-11-05",
1061
1058
  capabilities: {
1062
- tools: {},
1059
+ tools: { listChanged: true },
1063
1060
  },
1064
1061
  serverInfo: {
1065
1062
  name: SERVER_NAME,
@@ -1071,6 +1068,7 @@ async function handleRpc(request) {
1071
1068
  }
1072
1069
 
1073
1070
  if (method === "notifications/initialized") {
1071
+ initialized = true;
1074
1072
  return;
1075
1073
  }
1076
1074
 
@@ -1079,7 +1077,7 @@ async function handleRpc(request) {
1079
1077
  const overrides = Array.isArray(status?.capabilities?.mcpToolOverrides)
1080
1078
  ? status.capabilities.mcpToolOverrides
1081
1079
  : [];
1082
- const tools = mergeToolOverrides(MCP_TOOLS, overrides);
1080
+ const tools = mergeToolOverrides([...MCP_TOOLS, ...EXTRA_MCP_TOOLS], overrides);
1083
1081
  writeResult(id, { tools });
1084
1082
  return;
1085
1083
  }
@@ -1089,9 +1087,13 @@ async function handleRpc(request) {
1089
1087
  const toolName = String(params.name ?? "");
1090
1088
  const args = typeof params.arguments === "object" && params.arguments ? params.arguments : {};
1091
1089
  try {
1090
+ if (EXTRA_MCP_TOOLS.some((tool) => tool.name === toolName)) {
1091
+ writeResult(id, textResult(await capabilities.dispatch(toolName, args)));
1092
+ return;
1093
+ }
1092
1094
  if (toolName === "launchchair_status") {
1093
1095
  const status = await api("/api/agent/v1");
1094
- writeResult(id, textResult(status));
1096
+ writeResult(id, textResult({ ...status, authenticated: true }));
1095
1097
  return;
1096
1098
  }
1097
1099
  if (toolName === "launchchair_create_project") {
@@ -1140,7 +1142,7 @@ async function handleRpc(request) {
1140
1142
  return;
1141
1143
  }
1142
1144
  if (toolName === "launchchair_continue_project") {
1143
- const payload = await continueProjectLoop(args);
1145
+ const payload = args.executionMode === "local_runner" ? await continueProjectLoop(args) : await capabilities.dispatch("launchchair_next_step", { ...args, executionMode: undefined, maxSteps: undefined });
1144
1146
  writeResult(id, textResult(payload));
1145
1147
  return;
1146
1148
  }
@@ -0,0 +1,227 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { bindProjectWorkspace } from "./project-workspace.mjs";
5
+
6
+ const string = { type: "string" };
7
+ const object = { type: "object", additionalProperties: true };
8
+ const patch = { type: "array", items: { type: "object", properties: { op: { type: "string", enum: ["add", "replace", "remove"] }, path: string, value: {} }, required: ["op", "path"], additionalProperties: false } };
9
+ const project = { projectId: string, projectName: string };
10
+ const bucket = { ...project, phaseKey: string, bucketKey: string };
11
+ const card = { ...project, cardId: string };
12
+ export const VALIDATION_GATE_SCHEMA = { anyOf: [{ type: "string", enum: ["very_high"] }, { type: "object", properties: { minPain: { type: "number", minimum: 0, maximum: 100 }, minOpportunity: { type: "number", minimum: 0, maximum: 100 }, maxSaturation: { type: "number", minimum: 0, maximum: 100 } }, additionalProperties: false }] };
13
+ export const MCP_WORKFLOW_INSTRUCTIONS = "Prefer launchchair_next_step: execute the returned claimed prompt in THIS host agent using its existing research, repository, GitHub, Vercel and Supabase tools, then launchchair_complete_run and repeat. Do not start a second model/CLI unless the user requests local-runner execution. The server cannot inherit or call the host's other plugins itself. Preserve a supplied idea by default, including when scores are low; report risks without rewriting the source problem, audience, solution or specified requirements. Only use ideaIntent=explore when the user requests discovery/pivots. Validation gates are opt-in: do not impose a gate on an established idea unless the user requested one. For 'VERY high' scores use validationGate=very_high (pain>=85, opportunity>=85, saturation<=30). Research real evidence, create separate candidates, finish validation and compare LaunchChair scorecards, never inflate evidence or use direction-option scores as validation. Stop at a failed gate. If no candidate qualifies, report that honestly. Use connected infrastructure plugins only after the gate passes, save their verified results with update_bucket plus executionEvidence, bind the repository, and continue. Present actual authorization/production SQL checkpoints; reuse existing plugin authorization. Do not claim external actions were done without evidence. Complete runnable QA yourself and record results; ask for human checks only when truly necessary. Never disclose bearer tokens or private login device codes.";
14
+
15
+ function tool(name, description, properties = {}, required = [], readOnly = false) {
16
+ return { name: `launchchair_${name}`, description, inputSchema: { type: "object", properties, required, additionalProperties: false }, annotations: { readOnlyHint: readOnly, destructiveHint: ["revoke_token", "cancel_run", "write_competitor_note", "upload_logo"].includes(name), openWorldHint: true } };
17
+ }
18
+
19
+ const definitions = [
20
+ ["list_competitor_notes", "Read competitor research notes in the current project.", project, [], "GET", "/competitor-notes"],
21
+ ["write_competitor_note", "Create, update, or delete a competitor note. Updating/deleting requires authorship or active owner access.", { ...project, operation: { type: "string", enum: ["create", "update", "delete"] }, noteId: string, competitorKey: string, competitorName: string, content: string }, ["operation"], "POST", "/competitor-notes"],
22
+ ["upload_logo", "Upload a PNG/WebP logo (base64, up to 1 MiB) or remove the existing logo with remove=true. Use the host's image tools to create the logo first.", { ...project, contentBase64: string, mimeType: { type: "string", enum: ["image/png", "image/webp"] }, generationPrompt: string, remove: { type: "boolean" } }, [], "POST", "/logo"],
23
+ ["research_sources", "Fetch LaunchChair research sources: Hacker News search, Product Hunt ranked posts, or a public HTTPS RSS feed (including Reddit/Indie Hackers). Returns source material without changing the idea. Inspect evidence before saving findings via bucket tools.", { ...project, source: { type: "string", enum: ["hn", "product_hunt", "rss"] }, query: string, url: string, limit: { type: "integer", minimum: 1, maximum: 40 } }, ["source"], "POST", "/research"],
24
+ ["search_media", "Search LaunchChair's Pexels integration for photos/videos. Retain source and creator attribution; save selected assets through the appropriate bucket.", { ...project, query: string, kind: { type: "string", enum: ["photo", "video"] }, limit: { type: "integer", minimum: 1, maximum: 40 } }, ["query"], "POST", "/media"],
25
+ ["get_starter", "Read the private LaunchChair starter manifest, then download batches of files (base64) using paths and treeSha. Paid, scoped, and validation-gated. Create/commit/deploy the repository with the host GitHub/Vercel/Supabase tools; no provider credentials are transferred to LaunchChair.", { ...project, paths: { type: "array", items: string, minItems: 1, maxItems: 20 }, treeSha: string }, [], "POST", "/starter"],
26
+ ["get_spec", "Read the normalized project spec, validation scores and saved idea intent.", project, [], "GET", "/spec"],
27
+ ["update_spec", "Advanced guarded spec patch. Requires spec.write; default tokens use bucket-specific tools. Cannot change idea intent or validation policy.", { ...project, patch }, ["patch"], "PATCH", "/spec"],
28
+ ["list_buckets", "List all phases/buckets and progress.", project, [], "GET", "/buckets"],
29
+ ["get_bucket", "Read a bucket's data, allowed patch paths and evidence requirements before editing.", bucket, ["phaseKey", "bucketKey"], "GET", "/buckets/:phaseKey/:bucketKey"],
30
+ ["update_bucket", "Save form/checklist data using allowed JSON Patch paths, or update status. For setup and checklists supply factual executionEvidence {source,checkedAt,summary} from the host's connected plugins/tests. Never store credentials. Read get_bucket first. Does not provision infrastructure itself.", { ...bucket, patch, status: { type: "string", enum: ["not_started", "in_progress", "done"] }, executionEvidence: object }, ["phaseKey", "bucketKey"], "PATCH", "/buckets/:phaseKey/:bucketKey"],
31
+ ["get_bucket_prompt", "Read a bucket's compiled prompt for execution by the current host agent. Brand supports variant=tagline or logo; campaign_builder requires campaignId. Save generated form output with update_bucket.", { ...bucket, variant: { type: "string", enum: ["tagline", "logo"] }, campaignId: string }, ["phaseKey", "bucketKey"], "GET", "/buckets/:phaseKey/:bucketKey/prompt"],
32
+ ["apply_bucket_update", "Apply validated SPEC_UPDATE JSON Patch for a prompt bucket. Prefer complete_run for claimed runs so validation/remediation executes.", { ...bucket, patch, compiledPrompt: string, templateKey: string, pastedResponse: string }, ["phaseKey", "bucketKey", "patch"], "POST", "/buckets/:phaseKey/:bucketKey/apply-spec-update"],
33
+ ["get_build_cards", "Inspect build cards, dependencies, SQL actions and QA checkpoints.", project, [], "GET", "/build-cards"],
34
+ ["update_build_card", "Record build-card status, acceptance, SQL completion and QA results only after verification. Supply executionEvidence {source,checkedAt,summary} when completing a card or checking results. This records results, it does not execute SQL or tests.", { ...card, status: { type: "string", enum: ["To Do", "In Progress", "Needs Review", "Done"] }, notes: string, acceptanceStatus: object, testingNotesStatus: object, sqlActionsStatus: object, executionEvidence: object }, ["cardId", "status"], "PATCH", "/build-cards/:cardId"],
35
+ ["get_build_card_prompt", "Read the compiled build-card prompt. Requires a passing validation gate when configured.", card, ["cardId"], "GET", "/build-cards/:cardId/prompt"],
36
+ ["apply_build_card_run", "Apply CHANGELOG_JSON/REQUIRES from an executed build card. Use the API payload contract returned by the catalog.", { ...card, payload: object }, ["cardId", "payload"], "POST", "/build-cards/:cardId/apply-run"],
37
+ ["export_mvp_spec", "Export the AI-ready MVP markdown specification.", project, [], "GET", "/exports/mvp-ai-spec"],
38
+ ["export_phase", "Export a phase synthesis as markdown.", { ...project, phaseKey: string }, ["phaseKey"], "GET", "/exports/phase/:phaseKey"],
39
+ ["get_runner_workspace", "Read expected repository and workspace requirements.", project, [], "GET", "/runner-workspace"],
40
+ ["get_validation_gate", "Evaluate the saved validation gate using current calculated LaunchChair scores.", project, [], "GET", "/validation-gate"],
41
+ ["set_validation_gate", "Set or tighten an explicitly user-requested gate. Does not authorize changing the user's idea. An existing gate cannot be weakened by this tool.", { ...project, validationGate: VALIDATION_GATE_SCHEMA }, ["validationGate"], "POST", "/validation-gate"],
42
+ ["list_runs", "List recent runs belonging to this token and project.", project, [], "GET", "/runs"],
43
+ ["queue_run", "Queue a specific prompt bucket or build-card run, including research reruns. Then claim it and execute in this host agent; complete_run applies and validates the result.", { ...bucket, cardId: string }, [], "POST", "/runs"],
44
+ ];
45
+
46
+ export const EXTRA_MCP_TOOLS = [
47
+ ...definitions.map(([name, description, properties, required, method]) => tool(name, description, properties, required, method === "GET")),
48
+ tool("login", "Start browser-assisted LaunchChair login/token creation without an existing token. Return the existing LaunchChair approval URL for the user; then poll login_status. No password or token is requested in chat.", { tokenName: string, restart: { type: "boolean" } }),
49
+ tool("login_status", "Poll the pending browser approval once. Respect retryAfterSeconds. On approval save the token privately and authenticate this MCP session immediately.", {}),
50
+ tool("logout", "Disconnect this MCP and remove its saved token. Optionally revoke the current token on the server first.", { revoke: { type: "boolean", default: false } }),
51
+ tool("list_tokens", "List redacted tokens. Requires fresh owner authorization with tokens.manage and workspace-wide access.", {}, [], true),
52
+ tool("create_token", "Create a token with no more privileges/lifetime than the current token. Store its secret in a private local file and return only metadata/path. Optionally activate it for this MCP. Requires tokens.manage from owner-approved login.", { name: string, projectIds: { type: "array", items: string }, scopes: { type: "array", items: string }, expiresAt: string, activate: { type: "boolean", default: false } }, ["name"]),
53
+ tool("revoke_token", "Revoke a token in the authorized workspace. Revoking the current token disconnects this MCP.", { tokenId: string }, ["tokenId"]),
54
+ tool("get_run", "Read a run's status and output. Do not execute a run already claimed by another worker.", { runId: string }, ["runId"], true),
55
+ tool("cancel_run", "Cancel a queued/running run so further results cannot be applied. Stop any host process separately.", { runId: string }, ["runId"]),
56
+ tool("next_step", "Queue and atomically claim ONE next step for THIS host agent, without spawning a separate runner. Execute its prompt with your own tools/plugins, complete_run, then call again. Returns forms/setup/gate checkpoints when applicable. Set stopPhaseKey=launch_sales for the entire Product workflow, or market_validation for research only.", { ...project, stopPhaseKey: string, completionTarget: { type: "string", enum: ["phase_complete", "build_board_ready"] }, ideaSnapshot: object, choiceSelections: { type: "array", items: object }, agentSetup: object }, []),
57
+ tool("bind_repository", "Bind a local clone after checking its Git origin against the repository saved in LaunchChair. No cloning or infrastructure provisioning occurs.", { ...project, cwd: string }, ["cwd"]),
58
+ tool("compare_projects", "Compare up to 20 candidate ideas using calculated validation scores and requested thresholds. Never mutates ideas or inflates scores; returns qualifying candidates separately.", { projectIds: { type: "array", items: string, minItems: 1, maxItems: 20 }, validationGate: VALIDATION_GATE_SCHEMA }, ["projectIds"], true),
59
+ tool("research_opportunities", "Get a bounded research workflow for an explicitly exploratory user request. Execute the research using this host's search tools, then create and validate separate LaunchChair candidates. This tool itself does not research or create projects.", { audience: string, maxCandidates: { type: "integer", minimum: 1, maximum: 20, default: 5 }, validationGate: VALIDATION_GATE_SCHEMA }, ["audience"], true),
60
+ ];
61
+
62
+ export function createMcpCapabilities({ config, configPath, api, resolveProjectId, onAuthChange }) {
63
+ let sessionToken;
64
+ let pollInFlight = false;
65
+ let authEpoch = 0;
66
+ const projectSteps = new Set();
67
+ const configDir = path.dirname(configPath);
68
+ function readConfig() { try { const value = JSON.parse(readFileSync(configPath, "utf8")); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } catch { return {}; } }
69
+ function saveConfig(changes) {
70
+ const next = { ...readConfig(), ...changes };
71
+ for (const key of Object.keys(next)) if (next[key] === undefined) delete next[key];
72
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
73
+ const temporary = `${configPath}.${randomUUID()}.tmp`;
74
+ writeFileSync(temporary, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
75
+ renameSync(temporary, configPath);
76
+ chmodSync(configPath, 0o600);
77
+ }
78
+ function currentToken() {
79
+ if (sessionToken !== undefined) return sessionToken;
80
+ const saved = readConfig();
81
+ if (saved.mcpLoggedOut === true) return "";
82
+ if (saved.mcpPreferSavedToken === true && saved.agentToken) return saved.agentToken;
83
+ return process.env.LAUNCHCHAIR_AGENT_TOKEN?.trim() || process.env.LAUNCHCHAIR_API_KEY?.trim() || saved.agentToken || "";
84
+ }
85
+ function activate(token) {
86
+ saveConfig({ agentToken: token, baseUrl: config.baseUrl, mcpLoggedOut: false, mcpPreferSavedToken: true, pendingMcpLogin: undefined });
87
+ sessionToken = token;
88
+ onAuthChange();
89
+ }
90
+ function disconnect() {
91
+ authEpoch += 1;
92
+ saveConfig({ agentToken: undefined, pendingMcpLogin: undefined, mcpLoggedOut: true });
93
+ sessionToken = "";
94
+ onAuthChange();
95
+ }
96
+ async function publicRequest(endpoint, body) {
97
+ const response = await fetch(`${config.baseUrl}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
98
+ const payload = await response.json();
99
+ if (response.status === 429) return { status: "pending", retryAfterSeconds: Math.max(5, Number(response.headers.get("retry-after")) || 10) };
100
+ if (!response.ok) {
101
+ const error = new Error(String(payload.error || payload.status || `HTTP ${response.status}`));
102
+ error.status = response.status;
103
+ throw error;
104
+ }
105
+ return payload;
106
+ }
107
+ async function loginStatus() {
108
+ const saved = readConfig().pendingMcpLogin;
109
+ if (!saved) return { status: currentToken() ? "authenticated" : "not_started", nextTool: currentToken() ? "launchchair_status" : "launchchair_login" };
110
+ if (Date.now() >= saved.expiresAt) { saveConfig({ pendingMcpLogin: undefined }); return { status: "expired", nextTool: "launchchair_login" }; }
111
+ if (pollInFlight || Date.now() < (saved.nextPollAt || 0)) return { status: "pending", retryAfterSeconds: Math.max(1, Math.ceil(((saved.nextPollAt || Date.now() + 5000) - Date.now()) / 1000)) };
112
+ pollInFlight = true;
113
+ const epoch = authEpoch;
114
+ try {
115
+ const result = await publicRequest("/api/agent/v1/cli-login/poll", { deviceCode: saved.deviceCode });
116
+ if (epoch !== authEpoch || readConfig().pendingMcpLogin?.deviceCode !== saved.deviceCode) return { status: "cancelled", nextTool: "launchchair_login" };
117
+ if (result.status === "approved" && result.token) {
118
+ activate(result.token);
119
+ return { status: "authenticated", tokenRecord: result.tokenRecord, nextTool: "launchchair_status", message: "Login approved; token saved privately. MCP is ready without restarting." };
120
+ }
121
+ const retryAfterSeconds = Math.max(5, Number(result.retryAfterSeconds || result.interval) || 5);
122
+ saveConfig({ pendingMcpLogin: { ...saved, nextPollAt: Date.now() + retryAfterSeconds * 1000 } });
123
+ return { status: result.status || "pending", retryAfterSeconds };
124
+ } catch (error) {
125
+ if ([400, 403, 404, 410].includes(error.status)) saveConfig({ pendingMcpLogin: undefined });
126
+ throw error;
127
+ } finally { pollInFlight = false; }
128
+ }
129
+ async function dispatch(name, args) {
130
+ if (name === "launchchair_login") {
131
+ let pending = readConfig().pendingMcpLogin;
132
+ if (!pending || pending.expiresAt <= Date.now() || args.restart === true) {
133
+ const epoch = ++authEpoch;
134
+ const started = await publicRequest("/api/agent/v1/cli-login/start", { clientName: "LaunchChair MCP", tokenName: args.tokenName || "LaunchChair MCP" });
135
+ if (epoch !== authEpoch) return { status: "cancelled", nextTool: "launchchair_login" };
136
+ if (!started.deviceCode || !started.verificationUriComplete) return { status: "rate_limited", retryAfterSeconds: started.retryAfterSeconds || 10 };
137
+ const approvalUrl = new URL(started.verificationUriComplete);
138
+ if (approvalUrl.origin !== new URL(config.baseUrl).origin) throw new Error("Login approval URL does not match the configured LaunchChair origin.");
139
+ pending = { deviceCode: started.deviceCode, userCode: started.userCode, approvalUrl: approvalUrl.toString(), expiresAt: Date.now() + Number(started.expiresIn || 600) * 1000, nextPollAt: 0 };
140
+ saveConfig({ pendingMcpLogin: pending });
141
+ }
142
+ return { status: "approval_required", approvalUrl: pending.approvalUrl, userCode: pending.userCode, expiresAt: new Date(pending.expiresAt).toISOString(), nextTool: "launchchair_login_status", message: "Open this LaunchChair URL, sign in if needed, and approve this MCP. The workspace owner authorizes token creation." };
143
+ }
144
+ if (name === "launchchair_login_status") return loginStatus();
145
+ if (name === "launchchair_logout") {
146
+ if (args.revoke === true && currentToken()) {
147
+ const tokens = await api("/api/agent/v1/tokens");
148
+ await api(`/api/agent/v1/tokens/${encodeURIComponent(tokens.currentTokenId)}/revoke`, { method: "POST", body: {} });
149
+ }
150
+ disconnect();
151
+ return { status: "logged_out", revoked: args.revoke === true };
152
+ }
153
+ if (name === "launchchair_list_tokens") return api("/api/agent/v1/tokens");
154
+ if (name === "launchchair_create_token") {
155
+ const { activate: useToken, ...body } = args;
156
+ const created = await api("/api/agent/v1/tokens", { method: "POST", body });
157
+ if (!created.token || !created.tokenRecord?.id) throw new Error("Token creation returned incomplete credentials.");
158
+ const credentialPath = path.join(configDir, "tokens", `${randomUUID()}.json`);
159
+ mkdirSync(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
160
+ writeFileSync(credentialPath, JSON.stringify({ agentToken: created.token, baseUrl: config.baseUrl, tokenRecord: created.tokenRecord }) + "\n", { mode: 0o600, flag: "wx" });
161
+ if (useToken === true) activate(created.token);
162
+ return { tokenRecord: created.tokenRecord, credentialPath, activated: useToken === true, message: "Token secret saved privately; do not print the credential file in chat." };
163
+ }
164
+ if (name === "launchchair_revoke_token") {
165
+ const result = await api(`/api/agent/v1/tokens/${encodeURIComponent(args.tokenId)}/revoke`, { method: "POST", body: {} });
166
+ if (result.currentTokenRevoked) disconnect();
167
+ return result;
168
+ }
169
+ if (name === "launchchair_get_run" || name === "launchchair_cancel_run") return api(`/api/agent/v1/runs/${encodeURIComponent(args.runId)}`, name.endsWith("cancel_run") ? { method: "PATCH", body: {} } : {});
170
+ if (name === "launchchair_next_step") {
171
+ const projectId = await resolveProjectId(args);
172
+ const { projectId: _id, projectName: _name, ...body } = args;
173
+ if (projectSteps.has(projectId)) return { status: "waiting_for_run", projectId, message: "A next_step request is already in progress for this project. Wait for its result." };
174
+ projectSteps.add(projectId);
175
+ try {
176
+ const step = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/loop/continue`, { method: "POST", body });
177
+ if (step.status === "needs_human_input" && step.current) return { ...step, hostAction: { tool: step.current.cardId ? "launchchair_get_build_cards" : "launchchair_get_bucket", arguments: { projectId, phaseKey: step.current.phaseKey, bucketKey: step.current.bucketKey }, instructions: "Inspect the checkpoint and act with existing user authorization and connected tools. Save verified form/QA results or select a choice consistent with the supplied idea. Ask the user only for information or authorization that is actually missing." } };
178
+ if (!["queued_run", "waiting_for_run"].includes(step.status) || !step.run?.id) return step;
179
+ const claimed = await api("/api/agent/v1/runs/next", { method: "POST", body: { projectId, runId: step.run.id, clientName: config.clientName, clientType: config.clientType, clientVersion: config.clientVersion } });
180
+ if (!claimed.run) return { ...step, run: { id: step.run.id }, status: "waiting_for_run", message: "This run is already claimed. Poll get_run; do not execute it again." };
181
+ return { ...step, status: "run_ready", run: claimed.run, execution: "host", nextTool: "launchchair_complete_run", instructions: MCP_WORKFLOW_INSTRUCTIONS };
182
+ } finally { projectSteps.delete(projectId); }
183
+ }
184
+ if (name === "launchchair_bind_repository") {
185
+ const projectId = await resolveProjectId(args);
186
+ const { workspace } = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/runner-workspace`);
187
+ const repositoryUrl = workspace?.repository?.repoUrl;
188
+ if (!repositoryUrl) throw new Error("Save the project repository URL in LaunchChair before binding a clone.");
189
+ const bound = bindProjectWorkspace({ configPath, projectId, cwd: args.cwd, repositoryUrl });
190
+ return { projectId, binding: bound };
191
+ }
192
+ if (name === "launchchair_research_opportunities") return { audience: args.audience, maxCandidates: args.maxCandidates || 5, validationGate: args.validationGate ?? null, ideaIntent: "explore", steps: ["Research the audience's complaints, current workflows, substitutes, willingness to pay and named competitors using the host's search tools. Cite real sources and distinguish hypotheses from evidence.", "Create separate Product candidates with ideaSnapshot, ideaIntent=explore and a stable idempotencyKey. Include validationGate only when the user requested a score threshold.", "For each candidate call next_step with stopPhaseKey=market_validation, execute the prompt, complete_run and repeat. Inspect errors and perform requested remediation.", "Compare using compare_projects. Build only a qualifying candidate; if none qualifies, continue bounded research or report the unmet thresholds honestly.", "Use host GitHub/Vercel/Supabase plugins after the gate passes, record verified setup with update_bucket, bind_repository, then next_step through launch_sales."], instructions: MCP_WORKFLOW_INSTRUCTIONS };
193
+ if (name === "launchchair_compare_projects") {
194
+ if (!Array.isArray(args.projectIds) || !args.projectIds.length || args.projectIds.length > 20) throw new Error("Provide 1–20 projectIds.");
195
+ const thresholds = args.validationGate && typeof args.validationGate === "object" ? { minPain: 85, minOpportunity: 85, maxSaturation: 30, ...args.validationGate } : { minPain: 85, minOpportunity: 85, maxSaturation: 30 };
196
+ if (Object.values(thresholds).some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100)) throw new Error("Validation thresholds must be numbers between 0 and 100.");
197
+ const candidates = [];
198
+ for (const projectId of [...new Set(args.projectIds)]) {
199
+ const payload = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/scorecards`);
200
+ const validation = payload.validationScorecards || payload.scorecards;
201
+ const cards = Array.isArray(validation) ? validation : validation?.scorecards || [];
202
+ const score = (id) => cards.find((entry) => entry.id === id)?.score;
203
+ const pain = score("icp_pain"), opportunity = score("wedge_opportunity"), saturation = score("market_saturation");
204
+ const qualifies = [pain, opportunity, saturation].every((value) => typeof value === "number") && pain >= thresholds.minPain && opportunity >= thresholds.minOpportunity && saturation <= thresholds.maxSaturation;
205
+ candidates.push({ projectId, qualifies, pain, opportunity, saturation, validationScorecards: payload });
206
+ }
207
+ return { thresholds, candidates, qualifyingProjectIds: candidates.filter((candidate) => candidate.qualifies).map((candidate) => candidate.projectId), message: "These are calculated LaunchChair validation scores, not a guarantee of market success. Preserve supplied ideas; only explore alternatives when requested." };
208
+ }
209
+ const definition = definitions.find(([short]) => name === `launchchair_${short}`);
210
+ if (!definition) return undefined;
211
+ const [, , , , method, suffix] = definition;
212
+ const projectId = await resolveProjectId(args);
213
+ let endpoint = `/api/agent/v1/projects/${encodeURIComponent(projectId)}${suffix.replace(/:(\w+)/g, (_, key) => {
214
+ if (!args[key]) throw new Error(`${key} is required.`);
215
+ return encodeURIComponent(args[key]);
216
+ })}`;
217
+ if (name === "launchchair_get_bucket_prompt") {
218
+ const query = new URLSearchParams();
219
+ if (args.variant) query.set("variant", args.variant);
220
+ if (args.campaignId) query.set("campaignId", args.campaignId);
221
+ if (query.size) endpoint += `?${query}`;
222
+ }
223
+ const body = args.payload || Object.fromEntries(Object.entries(args).filter(([key]) => !["projectId", "projectName", ...(name === "launchchair_queue_run" ? [] : ["phaseKey", "bucketKey", "cardId"])].includes(key)));
224
+ return api(endpoint, method === "GET" ? {} : { method, body });
225
+ }
226
+ return { currentToken, dispatch };
227
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchchair/agent",
3
- "version": "0.4.12",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.launchchair/agent",
5
5
  "description": "LaunchChair Agent CLI, MCP bridge, local bridge, and runners for Codex, Claude Code, ChatGPT, Claude, and Hermes workflows.",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  "launchchair-cli.mjs",
16
16
  "local-bridge.mjs",
17
17
  "mcp-bridge-server.mjs",
18
+ "mcp-capabilities.mjs",
18
19
  "project-workspace.mjs",
19
20
  "runner-command.mjs",
20
21
  "codex-runner.mjs",
@@ -91,7 +91,10 @@ export function readGitOrigin(repoPath) {
91
91
  const gitDir = gitDirectory(repoPath);
92
92
  if (!gitDir) return "";
93
93
  try {
94
- const raw = readFileSync(path.join(gitDir, "config"), "utf8");
94
+ // Linked worktrees keep remote configuration in their common Git directory.
95
+ const commonPointer = path.join(gitDir, "commondir");
96
+ const commonDir = existsSync(commonPointer) ? path.resolve(gitDir, readFileSync(commonPointer, "utf8").trim()) : gitDir;
97
+ const raw = readFileSync(path.join(commonDir, "config"), "utf8");
95
98
  const remoteBlock = raw.match(/\[remote\s+"origin"\]([\s\S]*?)(?=\n\[|$)/i)?.[1] ?? "";
96
99
  return remoteBlock.match(/^\s*url\s*=\s*(.+)$/im)?.[1]?.trim() ?? "";
97
100
  } catch {
package/server.json CHANGED
@@ -3,13 +3,13 @@
3
3
  "name": "io.launchchair/agent",
4
4
  "title": "LaunchChair",
5
5
  "description": "Plan, validate, and build products and features with LaunchChair.",
6
- "version": "0.4.12",
6
+ "version": "0.5.0",
7
7
  "websiteUrl": "https://www.launchchair.io/docs/mcp",
8
8
  "packages": [
9
9
  {
10
10
  "registryType": "npm",
11
11
  "identifier": "@launchchair/agent",
12
- "version": "0.4.12",
12
+ "version": "0.5.0",
13
13
  "runtimeHint": "npx",
14
14
  "runtimeArguments": [
15
15
  {
@@ -29,8 +29,8 @@
29
29
  "environmentVariables": [
30
30
  {
31
31
  "name": "LAUNCHCHAIR_AGENT_TOKEN",
32
- "description": "LaunchChair Agent API token from Settings > API Access.",
33
- "isRequired": true,
32
+ "description": "Optional pre-existing token. Without it, call launchchair_login for browser approval and private token storage.",
33
+ "isRequired": false,
34
34
  "isSecret": true,
35
35
  "format": "string"
36
36
  }