@launchchair/agent 0.4.11 → 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.
@@ -14,8 +14,13 @@ import os from "node:os";
14
14
  import path from "node:path";
15
15
  import process from "node:process";
16
16
  import { fileURLToPath } from "node:url";
17
+ import {
18
+ bindProjectWorkspace,
19
+ listProjectWorkspaceBindings,
20
+ unbindProjectWorkspace,
21
+ } from "./project-workspace.mjs";
17
22
 
18
- const VERSION = "0.4.11";
23
+ const VERSION = "0.5.0";
19
24
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
20
25
  const DEFAULT_BASE_URL = "https://www.launchchair.io";
21
26
  const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
@@ -104,10 +109,16 @@ function configValue(key, envNames = [], fallback = "") {
104
109
  }
105
110
 
106
111
  function token() {
112
+ const config = loadConfig();
113
+ if (config.mcpLoggedOut === true) return "";
114
+ if (config.mcpPreferSavedToken === true && config.agentToken) return config.agentToken;
107
115
  return configValue("agentToken", ["LAUNCHCHAIR_AGENT_TOKEN", "LAUNCHCHAIR_API_KEY"]);
108
116
  }
109
117
 
110
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;
111
122
  if (env("LAUNCHCHAIR_AGENT_TOKEN")) return "LAUNCHCHAIR_AGENT_TOKEN";
112
123
  if (env("LAUNCHCHAIR_API_KEY")) return "LAUNCHCHAIR_API_KEY";
113
124
  if (loadConfig().agentToken) return CONFIG_PATH;
@@ -166,6 +177,7 @@ Use MCP-only when you want agent tools inside Codex/Claude/Hermes.
166
177
  Use CLI + MCP when you also want quick terminal commands, diagnostics, and bridge control.
167
178
 
168
179
  Commands:
180
+ --version Show the installed package version
169
181
  doctor Check token, docs, API reachability, and local bridge files
170
182
  login [--token TOKEN] Open browser login and save a LaunchChair Agent API token locally
171
183
  logout Remove the saved local token
@@ -178,10 +190,15 @@ Commands:
178
190
  run-once Claim and run one queued LaunchChair job through the local bridge
179
191
  bridge Start the long-running local bridge for Run with Agent
180
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
181
196
  setup codex|claude|claude-desktop|claude-code|terminal [options]
182
- Guided setup for CLI, CWD, AGENTS.md, MCP config, and bridge
197
+ Guided global CLI/MCP setup with isolated project workspaces
198
+ bind --project-id ID --cwd PATH Bind one LaunchChair project to a verified local Git clone
199
+ unbind --project-id ID Remove one project's saved local repository binding
183
200
  config Show local CLI config without printing the token
184
- config set KEY VALUE Save base-url, project-id, codex-cwd, or claude-code-cwd
201
+ config set KEY VALUE Save base-url or project-id
185
202
  mcp-config codex|claude [options]
186
203
  Print MCP config or Codex custom MCP form fields
187
204
  install-agent-md [--cwd PATH] Create a small AGENTS.md pointer in a fresh repo
@@ -195,6 +212,8 @@ Create options:
195
212
  --start-loop
196
213
  --save-project Save the created project id as the CLI default
197
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
198
217
 
199
218
  Feature create options:
200
219
  --project-kind feature
@@ -215,7 +234,7 @@ Continue options:
215
234
  --completion-target TEXT Natural-language target for the loop run
216
235
 
217
236
  Setup options:
218
- --cwd PATH Repo folder or empty folder the agent should build into
237
+ --cwd PATH Bind this repo only when --project-id is also supplied
219
238
  --token TOKEN Optional manual lc_at token fallback to save during setup
220
239
  --project-id ID Optional default LaunchChair project id
221
240
  --base-url URL Optional LaunchChair base URL
@@ -238,16 +257,21 @@ Login options:
238
257
  --timeout SECONDS Default: 600
239
258
 
240
259
  Env:
241
- LAUNCHCHAIR_AGENT_TOKEN Optional full lc_at token override
260
+ LAUNCHCHAIR_AGENT_TOKEN Optional token fallback when no saved login takes precedence
242
261
  LAUNCHCHAIR_BASE_URL Default: https://www.launchchair.io
243
262
  LAUNCHCHAIR_PROJECT_ID Optional default project id
244
- LC_AGENT_CODEX_CWD Folder Codex should build into
245
- LC_AGENT_CLAUDE_CODE_CWD Folder Claude Code should build into
263
+ LC_AGENT_CODEX_CWD Legacy candidate only; verified per project before use
264
+ LC_AGENT_CLAUDE_CODE_CWD Legacy candidate only; verified per project before use
246
265
 
247
266
  MCP config options:
248
267
  --ui-fields Print fields for Codex Desktop's custom MCP form
249
268
  --copy-config Copy generated output to clipboard on macOS
250
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
+
251
275
  Config:
252
276
  ${CONFIG_PATH}
253
277
  `);
@@ -301,7 +325,13 @@ async function api(pathname, options = {}) {
301
325
  if (!response.ok) {
302
326
  const retryAfter = response.headers.get("retry-after");
303
327
  const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
304
- 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;
305
335
  }
306
336
  return body;
307
337
  }
@@ -324,7 +354,10 @@ async function publicApi(pathname, options = {}) {
324
354
  if (!response.ok) {
325
355
  const retryAfter = response.headers.get("retry-after");
326
356
  const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
327
- 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;
328
361
  }
329
362
  return body;
330
363
  }
@@ -384,6 +417,8 @@ async function login(args) {
384
417
  }
385
418
  saveConfig({
386
419
  agentToken: incomingToken,
420
+ mcpLoggedOut: false,
421
+ mcpPreferSavedToken: true,
387
422
  baseUrl: incomingBaseUrl ?? baseUrl(),
388
423
  projectId: incomingProjectId || defaultProjectId() || undefined,
389
424
  });
@@ -427,7 +462,7 @@ async function login(args) {
427
462
 
428
463
  const timeoutSeconds =
429
464
  typeof args.timeout === "string" && Number.isFinite(Number(args.timeout)) ? Math.max(10, Number(args.timeout)) : Number(start.expiresIn ?? 600);
430
- const intervalSeconds = Math.max(2, Number(start.interval ?? 2));
465
+ let intervalSeconds = Math.max(5, Number(start.interval ?? 5));
431
466
  const startedAt = Date.now();
432
467
  print("");
433
468
  print("Waiting for approval. Leave this terminal open.");
@@ -442,10 +477,12 @@ async function login(args) {
442
477
  });
443
478
  } catch (error) {
444
479
  const message = error instanceof Error ? error.message : String(error);
480
+ if (error.status === 429) { intervalSeconds = Math.max(5, error.retryAfterSeconds); continue; }
445
481
  if (/pending/i.test(message)) continue;
446
482
  throw error;
447
483
  }
448
484
  if (poll?.status === "pending") {
485
+ intervalSeconds = Math.max(5, Number(poll.interval) || intervalSeconds);
449
486
  write(paint(".", "dim"));
450
487
  continue;
451
488
  }
@@ -453,6 +490,8 @@ async function login(args) {
453
490
  print("");
454
491
  saveConfig({
455
492
  agentToken: poll.token,
493
+ mcpLoggedOut: false,
494
+ mcpPreferSavedToken: true,
456
495
  baseUrl: baseUrl(),
457
496
  projectId: incomingProjectId || defaultProjectId() || undefined,
458
497
  });
@@ -471,11 +510,12 @@ async function login(args) {
471
510
  }
472
511
 
473
512
  function logout() {
474
- deleteConfigKeys(["agentToken"]);
513
+ deleteConfigKeys(["agentToken", "pendingMcpLogin"]);
514
+ saveConfig({ mcpLoggedOut: true });
475
515
  title("CLI logout");
476
516
  ok(`Removed saved token from ${CONFIG_PATH}`);
477
517
  if (env("LAUNCHCHAIR_AGENT_TOKEN") || env("LAUNCHCHAIR_API_KEY")) {
478
- 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.");
479
519
  }
480
520
  }
481
521
 
@@ -599,6 +639,14 @@ async function createProject(args) {
599
639
  throw new Error("--project-kind must be product or feature.");
600
640
  }
601
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
+ }
602
650
  const featureDescription = String(args["feature-description"] ?? args["one-liner"] ?? "").trim();
603
651
  const featureProblem = String(args.problem ?? "").trim();
604
652
  if (featureProject && (!featureDescription || !featureProblem)) {
@@ -657,7 +705,7 @@ async function createProject(args) {
657
705
  const body = await api("/api/agent/v1/projects", {
658
706
  method: "POST",
659
707
  headers,
660
- body: JSON.stringify(requestBody),
708
+ body: JSON.stringify({ ...requestBody, ideaIntent, validationGate }),
661
709
  });
662
710
  const projectId = body?.project?.id ?? body?.projectId ?? "";
663
711
  title("Project created");
@@ -767,9 +815,9 @@ function buildBridgeEnv(projectId = defaultProjectId()) {
767
815
  LAUNCHCHAIR_CONFIG_DIR: CONFIG_DIR,
768
816
  LAUNCHCHAIR_AGENT_TOKEN: token(),
769
817
  LAUNCHCHAIR_PROJECT_ID: projectId,
770
- LC_AGENT_CODEX_CWD: configValue("codexCwd", ["LC_AGENT_CODEX_CWD"], ""),
771
- LC_AGENT_CLAUDE_CODE_CWD: configValue("claudeCodeCwd", ["LC_AGENT_CLAUDE_CODE_CWD"], ""),
772
818
  };
819
+ delete childEnv.LC_AGENT_CODEX_CWD;
820
+ delete childEnv.LC_AGENT_CLAUDE_CODE_CWD;
773
821
  Object.keys(childEnv).forEach((key) => {
774
822
  if (childEnv[key] === undefined || childEnv[key] === null || childEnv[key] === "") {
775
823
  delete childEnv[key];
@@ -869,6 +917,100 @@ function spawnMcpServer(extraArgs = []) {
869
917
  });
870
918
  }
871
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
+
872
1014
  function normalizedMcpTarget(target) {
873
1015
  return targetDetails(target || "codex").mcpTarget || "codex";
874
1016
  }
@@ -878,18 +1020,14 @@ function tomlString(value) {
878
1020
  }
879
1021
 
880
1022
  function mcpEnvForTarget(target, options = {}) {
881
- const includeTokenPlaceholder = options.includeTokenPlaceholder !== false;
882
1023
  const details = targetDetails(target || "codex");
883
1024
  const projectId = defaultProjectId();
884
- const cwd = configValue(details.cwdConfigKey, [details.cwdEnvName], "/absolute/path/to/repo-or-empty-folder");
885
1025
  const envBlock = {
886
1026
  LAUNCHCHAIR_BASE_URL: baseUrl(),
887
1027
  LAUNCHCHAIR_CONFIG_DIR: CONFIG_DIR,
888
- ...(token() || !includeTokenPlaceholder ? {} : { LAUNCHCHAIR_AGENT_TOKEN: "<optional full lc_at token when launchchair login was not used>" }),
889
1028
  ...(projectId ? { LAUNCHCHAIR_PROJECT_ID: projectId } : {}),
890
1029
  LC_AGENT_ROUTER_GPT_TARGET: details.routerTarget,
891
1030
  LC_AGENT_ROUTER_CODEX_TARGET: details.routerTarget,
892
- [details.cwdEnvName]: cwd,
893
1031
  };
894
1032
  return Object.fromEntries(Object.entries(envBlock).filter(([, value]) => String(value ?? "").trim()));
895
1033
  }
@@ -911,6 +1049,7 @@ function verifyMcpServer(target, timeoutMs = 20_000) {
911
1049
  stdio: ["pipe", "pipe", "pipe"],
912
1050
  });
913
1051
  let settled = false;
1052
+ let discoveredTools = [];
914
1053
  let stdout = "";
915
1054
  let stderr = "";
916
1055
 
@@ -961,13 +1100,29 @@ function verifyMcpServer(target, timeoutMs = 20_000) {
961
1100
  const toolNames = Array.isArray(response?.result?.tools)
962
1101
  ? response.result.tools.map((tool) => String(tool?.name || "")).filter(Boolean)
963
1102
  : [];
964
- 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
+ ];
965
1109
  const missingTools = requiredTools.filter((name) => !toolNames.includes(name));
966
1110
  if (missingTools.length) {
967
1111
  finish(new Error(`MCP verification did not discover required tools: ${missingTools.join(", ")}`));
968
1112
  return;
969
1113
  }
970
- 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);
971
1126
  }
972
1127
  }
973
1128
  });
@@ -1025,13 +1180,12 @@ ${envLines}`;
1025
1180
 
1026
1181
  function renderMcpUiFields(target) {
1027
1182
  const normalized = normalizedMcpTarget(target);
1028
- const details = targetDetails(normalized);
1029
1183
  const server = mcpServerConfig(normalized);
1030
1184
  const envLines =
1031
1185
  Object.entries(server.env)
1032
1186
  .map(([key, value]) => ` ${key} = ${value}`)
1033
1187
  .join("\n") || " (none)";
1034
- const workingDir = server.env[details.cwdEnvName] || process.cwd();
1188
+ const workingDir = path.join(CONFIG_DIR, "projects");
1035
1189
  return `Name
1036
1190
  launchchair
1037
1191
 
@@ -1173,14 +1327,20 @@ function printMcpConfig(target, args = {}) {
1173
1327
 
1174
1328
  function showConfig() {
1175
1329
  const config = loadConfig();
1330
+ const bindings = listProjectWorkspaceBindings(CONFIG_PATH);
1176
1331
  title("CLI config");
1177
1332
  row("Path", CONFIG_PATH, existsSync(CONFIG_PATH) ? "dim" : "yellow");
1178
1333
  row("Base URL", baseUrl(), "cyan");
1179
1334
  row("Token", maskToken(token()), token() ? "green" : "yellow");
1180
1335
  row("Token source", tokenSource(), token() ? "green" : "yellow");
1181
1336
  row("Project ID", defaultProjectId() || "not set", defaultProjectId() ? "cyan" : "dim");
1182
- row("Codex CWD", configValue("codexCwd", ["LC_AGENT_CODEX_CWD"], "not set"), config.codexCwd || env("LC_AGENT_CODEX_CWD") ? "cyan" : "dim");
1183
- row("Claude Code CWD", configValue("claudeCodeCwd", ["LC_AGENT_CLAUDE_CODE_CWD"], "not set"), config.claudeCodeCwd || env("LC_AGENT_CLAUDE_CODE_CWD") ? "cyan" : "dim");
1337
+ row("Project bindings", Object.keys(bindings).length, Object.keys(bindings).length > 0 ? "cyan" : "dim");
1338
+ for (const [projectId, binding] of Object.entries(bindings)) {
1339
+ row(` ${projectId.slice(0, 12)}`, typeof binding === "string" ? binding : binding?.path ?? "invalid", "cyan");
1340
+ }
1341
+ if (config.codexCwd || config.claudeCodeCwd || env("LC_AGENT_CODEX_CWD") || env("LC_AGENT_CLAUDE_CODE_CWD")) {
1342
+ row("Legacy CWD", "ignored unless its Git origin matches the current project", "yellow");
1343
+ }
1184
1344
  }
1185
1345
 
1186
1346
  function setConfig(args) {
@@ -1189,16 +1349,50 @@ function setConfig(args) {
1189
1349
  const allowed = {
1190
1350
  "base-url": "baseUrl",
1191
1351
  "project-id": "projectId",
1192
- "codex-cwd": "codexCwd",
1193
- "claude-code-cwd": "claudeCodeCwd",
1194
1352
  };
1195
1353
  if (!key || !value || !allowed[key]) {
1196
- throw new Error("Usage: launchchair config set base-url|project-id|codex-cwd|claude-code-cwd VALUE");
1354
+ throw new Error("Usage: launchchair config set base-url|project-id VALUE. Use launchchair bind for local repositories.");
1197
1355
  }
1198
1356
  saveConfig({ [allowed[key]]: key === "base-url" ? value.replace(/\/+$/, "") : value });
1199
1357
  ok(`Saved ${key} to ${CONFIG_PATH}`);
1200
1358
  }
1201
1359
 
1360
+ async function getRunnerWorkspaceContext(projectId) {
1361
+ const payload = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/runner-workspace`);
1362
+ return payload?.workspace && typeof payload.workspace === "object" ? payload.workspace : { projectId };
1363
+ }
1364
+
1365
+ async function bindProject(args) {
1366
+ const projectId = String(args["project-id"] || defaultProjectId() || "").trim();
1367
+ const cwd = String(args.cwd || "").trim();
1368
+ if (!projectId || !cwd) {
1369
+ throw new Error("Usage: launchchair bind --project-id ID --cwd /absolute/path/to/repo");
1370
+ }
1371
+ const context = await getRunnerWorkspaceContext(projectId);
1372
+ const repository = context?.repository && typeof context.repository === "object" ? context.repository : {};
1373
+ const binding = bindProjectWorkspace({
1374
+ configPath: CONFIG_PATH,
1375
+ projectId,
1376
+ cwd,
1377
+ repositoryUrl: String(repository.repoUrl || repository.cloneUrl || "").trim(),
1378
+ });
1379
+ installAgentMd({ cwd: binding.path });
1380
+ title("project repository bound");
1381
+ row("Project ID", projectId, "cyan");
1382
+ row("Local repository", binding.path, "cyan");
1383
+ if (binding.repositoryUrl) row("Verified origin", binding.repositoryUrl, "green");
1384
+ ok("Future runs for this LaunchChair project will use this repository.");
1385
+ ok("Already-running bridges will pick up this binding on the next run.");
1386
+ }
1387
+
1388
+ function unbindProject(args) {
1389
+ const projectId = String(args["project-id"] || defaultProjectId() || "").trim();
1390
+ if (!projectId) throw new Error("Usage: launchchair unbind --project-id ID");
1391
+ const existed = unbindProjectWorkspace({ configPath: CONFIG_PATH, projectId });
1392
+ if (existed) ok(`Removed local repository binding for ${projectId}.`);
1393
+ else warn(`No local repository binding existed for ${projectId}.`);
1394
+ }
1395
+
1202
1396
  function installAgentMd(args) {
1203
1397
  const cwd = path.resolve(String(args.cwd || process.cwd()));
1204
1398
  const target = path.join(cwd, "AGENTS.md");
@@ -1242,7 +1436,10 @@ function installLaunchChairSkill(args) {
1242
1436
  "# LaunchChair workflow",
1243
1437
  "",
1244
1438
  "Use the installed LaunchChair MCP tools before web search or local repo discovery for LaunchChair project work.",
1245
- "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.",
1246
1443
  "For a new product, use launchchair_create_project.",
1247
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.",
1248
1445
  "Use planToBuildBoard=true for planning through the Feature build board.",
@@ -1258,17 +1455,23 @@ function installLaunchChairSkill(args) {
1258
1455
 
1259
1456
  async function setup(args) {
1260
1457
  const details = targetDetails(args._[0] || "codex");
1261
- const cwd = path.resolve(String(args.cwd || process.cwd()));
1458
+ const requestedCwd = typeof args.cwd === "string" && args.cwd.trim() ? path.resolve(args.cwd.trim()) : "";
1262
1459
  const writeConfig =
1263
1460
  args["write-config"] === true ||
1264
1461
  (args["copy-config"] !== true && args["ui-fields"] !== true && args["print-config"] !== true);
1265
1462
  const incomingToken = typeof args.token === "string" ? args.token.trim() : "";
1266
1463
  const incomingBaseUrl = typeof args["base-url"] === "string" ? args["base-url"].trim().replace(/\/+$/, "") : undefined;
1267
1464
  const incomingProjectId = typeof args["project-id"] === "string" ? args["project-id"].trim() : "";
1465
+ const bindingProjectId = incomingProjectId || defaultProjectId();
1466
+
1467
+ if (requestedCwd && !bindingProjectId) {
1468
+ throw new Error("--cwd must be paired with --project-id. Global runner directories are no longer supported.");
1469
+ }
1268
1470
 
1269
1471
  title(`guided setup for ${details.label}`);
1270
- mkdirSync(cwd, { recursive: true });
1271
- ok(`Build folder ready: ${cwd}`);
1472
+ const projectWorkspaceRoot = path.join(CONFIG_DIR, "projects");
1473
+ mkdirSync(projectWorkspaceRoot, { recursive: true, mode: 0o700 });
1474
+ ok(`Isolated project workspace root ready: ${projectWorkspaceRoot}`);
1272
1475
 
1273
1476
  if (incomingToken) {
1274
1477
  if (!incomingToken.startsWith("lc_at_")) {
@@ -1276,6 +1479,8 @@ async function setup(args) {
1276
1479
  }
1277
1480
  saveConfig({
1278
1481
  agentToken: incomingToken,
1482
+ mcpLoggedOut: false,
1483
+ mcpPreferSavedToken: true,
1279
1484
  baseUrl: incomingBaseUrl ?? baseUrl(),
1280
1485
  projectId: incomingProjectId || defaultProjectId() || undefined,
1281
1486
  });
@@ -1287,10 +1492,6 @@ async function setup(args) {
1287
1492
  });
1288
1493
  }
1289
1494
 
1290
- saveConfig({ [details.cwdConfigKey]: cwd });
1291
- ok(`Saved ${details.cwdEnvName}: ${cwd}`);
1292
- installAgentMd({ cwd });
1293
-
1294
1495
  if (!token() && args["skip-login"] !== true) {
1295
1496
  print("");
1296
1497
  warn("No saved LaunchChair token found. Starting browser approval now.");
@@ -1306,8 +1507,12 @@ async function setup(args) {
1306
1507
  });
1307
1508
  }
1308
1509
 
1510
+ if (requestedCwd) {
1511
+ await bindProject({ "project-id": bindingProjectId, cwd: requestedCwd });
1512
+ }
1513
+
1309
1514
  if (details.mcpTarget === "codex") {
1310
- installLaunchChairSkill({ cwd });
1515
+ installLaunchChairSkill({ cwd: path.dirname(CONFIG_DIR) });
1311
1516
  }
1312
1517
 
1313
1518
  print("");
@@ -1362,11 +1567,11 @@ async function setup(args) {
1362
1567
  }
1363
1568
  }
1364
1569
 
1365
- if (!details.terminalOnly && token() && args["skip-mcp-check"] !== true) {
1570
+ if (!details.terminalOnly && args["skip-mcp-check"] !== true) {
1366
1571
  const toolNames = await verifyMcpServer(details.mcpTarget);
1367
1572
  print("");
1368
- title("MCP verification passed");
1369
- 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."}`);
1370
1575
  } else if (!details.terminalOnly && !token()) {
1371
1576
  warn("MCP verification was skipped because no LaunchChair token is configured.");
1372
1577
  } else if (!details.terminalOnly && args["skip-mcp-check"] === true) {
@@ -1405,12 +1610,13 @@ async function setup(args) {
1405
1610
  print(`2. Restart or reload ${details.label} so the MCP server is exposed to fresh chats.`);
1406
1611
  }
1407
1612
  print("Use the generated command and argument fields exactly as printed; setup resolves the local Node and CLI paths for this machine.");
1408
- 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:");
1409
1615
  print(` ${paint("launchchair bridge", "cyan")}`);
1410
1616
  print("Start a fresh agent chat. The agent should discover the LaunchChair MCP tools automatically.");
1411
1617
  }
1412
1618
  print("");
1413
- row("Build folder", cwd, "cyan");
1619
+ row("Workspace mode", requestedCwd ? `project-bound: ${requestedCwd}` : "isolated per LaunchChair project", "cyan");
1414
1620
  row("Runner target", details.routerTarget, "cyan");
1415
1621
  row("Config file", CONFIG_PATH, "dim");
1416
1622
 
@@ -1429,6 +1635,7 @@ async function main() {
1429
1635
  const [command = "help", ...rest] = process.argv.slice(2);
1430
1636
  const args = parseArgs(rest);
1431
1637
  if (command === "help" || command === "--help" || command === "-h") return help();
1638
+ if (command === "--version" || command === "-v" || command === "version") return print(VERSION);
1432
1639
  if (command === "doctor") return doctor();
1433
1640
  if (command === "login") return login(args);
1434
1641
  if (command === "logout") return logout();
@@ -1440,7 +1647,11 @@ async function main() {
1440
1647
  if (command === "run-once") return spawnBridge(["--once", "--verbose"]);
1441
1648
  if (command === "bridge") return spawnBridge(["--verbose"]);
1442
1649
  if (command === "mcp-server") return spawnMcpServer();
1650
+ if (command === "tools") return listTools(args);
1651
+ if (command === "call") return callTool(args);
1443
1652
  if (command === "setup") return setup(args);
1653
+ if (command === "bind") return bindProject(args);
1654
+ if (command === "unbind") return unbindProject(args);
1444
1655
  if (command === "config" && args._[0] === "set") return setConfig({ ...args, _: args._.slice(1) });
1445
1656
  if (command === "config") return showConfig();
1446
1657
  if (command === "mcp-config") return printMcpConfig(args._[0] || "codex", args);