@launchchair/agent 0.4.10 → 0.4.12

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
@@ -6,7 +6,7 @@ This file is for fresh agent chats that need to use the LaunchChair Agent API or
6
6
 
7
7
  Use LaunchChair first for product, research, validation, positioning, pricing, MVP blueprint, and phase-loop work. Do not inspect the local repo first unless the user is in Stack Setup, Build MVP, Design and Landing Page implementation, or another code-changing phase.
8
8
 
9
- Fresh LaunchChair projects often do not have a local repo yet. Before Stack Setup, the configured runner folder may be empty on purpose.
9
+ Fresh LaunchChair projects often do not have a local repo yet. Before Stack Setup, each project uses its own isolated workspace under `~/.launchchair/projects`.
10
10
 
11
11
  ## Public Docs To Read
12
12
 
@@ -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/project-workspace.mjs" -o "$HOME/launchchair-agent/project-workspace.mjs"
38
39
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/runner-command.mjs" -o "$HOME/launchchair-agent/runner-command.mjs"
39
40
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/codex-runner.mjs" -o "$HOME/launchchair-agent/codex-runner.mjs"
40
41
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/claude-code-runner.mjs" -o "$HOME/launchchair-agent/claude-code-runner.mjs"
@@ -42,21 +43,8 @@ curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/chatgpt-runner.mjs" -
42
43
  curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/claude-runner.mjs" -o "$HOME/launchchair-agent/claude-runner.mjs"
43
44
  curl -fsSL "https://www.launchchair.io/agent-artifacts/AGENTS.md" -o "$HOME/launchchair-agent/AGENTS.md"
44
45
  chmod +x "$HOME/launchchair-agent"/*.mjs
45
- LAUNCHCHAIR_AGENT_WORKDIR="${LC_AGENT_CODEX_CWD:-${LC_AGENT_CLAUDE_CODE_CWD:-$PWD}}"
46
- if [ -d "$LAUNCHCHAIR_AGENT_WORKDIR" ] && [ ! -f "$LAUNCHCHAIR_AGENT_WORKDIR/AGENTS.md" ]; then
47
- printf '%s\n' \
48
- '# LaunchChair Agent Bootstrap Pointer' \
49
- '' \
50
- 'Before searching this repo, read ~/launchchair-agent/AGENTS.md.' \
51
- 'Use LaunchChair MCP/API first for product phases, project discovery, project creation, and phase loops.' \
52
- 'Do not ask for extra confirmation before using LaunchChair MCP/API when the user already requested the action and the token permits it.' \
53
- 'Only inspect this repo for Stack Setup, Build MVP, Design/Landing Page, SEO implementation, smoke testing, security review, or code remediation.' \
54
- > "$LAUNCHCHAIR_AGENT_WORKDIR/AGENTS.md"
55
- fi
56
46
  ```
57
47
 
58
- The final block creates a tiny project-local `AGENTS.md` pointer only when the target folder does not already have one. It helps fresh Codex chats auto-load the LaunchChair instructions without overwriting a real project instruction file.
59
-
60
48
  ## CLI
61
49
 
62
50
  The npm CLI is the recommended setup path for Codex Desktop and Claude Desktop. It can log in through LaunchChair, save the token locally, print paste-ready MCP config, write Claude Desktop config, and start the local bridge.
@@ -64,8 +52,9 @@ The npm CLI is the recommended setup path for Codex Desktop and Claude Desktop.
64
52
  ```bash
65
53
  npm install -g @launchchair/agent
66
54
  launchchair login
67
- launchchair setup codex --cwd /absolute/path/to/repo-or-empty-folder
68
- launchchair setup claude --cwd /absolute/path/to/repo-or-empty-folder
55
+ launchchair setup codex
56
+ launchchair setup claude
57
+ launchchair bind --project-id PROJECT_UUID --cwd /absolute/path/to/verified-clone
69
58
  launchchair projects --query LeafLore
70
59
  launchchair create --name SignalLift --one-liner "Distribution workspace for technical founders" --project-type b2b --idempotency-key signallift-v1
71
60
  launchchair run-once
@@ -76,7 +65,7 @@ launchchair mcp-config codex --ui-fields
76
65
  If Codex Desktop is being configured through the custom MCP form instead of `~/.codex/config.toml`, run:
77
66
 
78
67
  ```bash
79
- launchchair setup codex --cwd /absolute/path/to/repo-or-empty-folder --copy-config
68
+ launchchair setup codex --copy-config
80
69
  ```
81
70
 
82
71
  ## Environment Variables
@@ -89,12 +78,12 @@ LAUNCHCHAIR_PROJECT_ID="optional project UUID; omit or leave blank if creating o
89
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.
90
79
  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.
91
80
 
92
- For Codex Desktop local runs:
81
+ For Codex Desktop local runs, choose the runner globally and bind each project clone independently:
93
82
 
94
83
  ```bash
95
84
  LC_AGENT_ROUTER_GPT_TARGET="codex"
96
85
  LC_AGENT_ROUTER_CODEX_TARGET="codex"
97
- LC_AGENT_CODEX_CWD="/absolute/path/to/the-folder-codex-should-build-into"
86
+ launchchair bind --project-id PROJECT_UUID --cwd /absolute/path/to/verified-clone
98
87
  ```
99
88
 
100
89
  For Claude Desktop plus Claude Code local runs:
@@ -102,10 +91,10 @@ For Claude Desktop plus Claude Code local runs:
102
91
  ```bash
103
92
  LC_AGENT_ROUTER_GPT_TARGET="claude_code"
104
93
  LC_AGENT_ROUTER_CODEX_TARGET="claude_code"
105
- LC_AGENT_CLAUDE_CODE_CWD="/absolute/path/to/the-folder-claude-code-should-build-into"
94
+ launchchair bind --project-id PROJECT_UUID --cwd /absolute/path/to/verified-clone
106
95
  ```
107
96
 
108
- The build folder can be an empty scratch folder until Stack Setup creates or records the GitHub repository. After Stack Setup, the user should clone the generated repo locally or update the runner CWD to the cloned repo path.
97
+ Pre-code work uses an isolated per-project scratch folder. After Stack Setup records the GitHub repository, the bridge auto-detects matching clones in standard repo folders and verifies the Git `origin`. If needed, clone the repo and run `launchchair bind`; code-changing runs fail closed until that verification succeeds.
109
98
 
110
99
  ## MCP Server Config Example
111
100
 
@@ -124,13 +113,12 @@ LAUNCHCHAIR_CONFIG_DIR = "/absolute/path/to/.launchchair"
124
113
  LAUNCHCHAIR_PROJECT_ID = ""
125
114
  LC_AGENT_ROUTER_GPT_TARGET = "codex"
126
115
  LC_AGENT_ROUTER_CODEX_TARGET = "codex"
127
- LC_AGENT_CODEX_CWD = "/absolute/path/to/the-folder-codex-should-build-into"
128
116
 
129
117
  # Optional fallback if launchchair login was skipped:
130
118
  # LAUNCHCHAIR_AGENT_TOKEN = "<full lc_at token from Settings API Access>"
131
119
  ```
132
120
 
133
- Prefer `launchchair setup codex --cwd /absolute/path/to/build-folder` so the CLI writes native config and verifies MCP tool discovery. Use `launchchair mcp-config codex --ui-fields` only for manual custom-form setup.
121
+ Prefer `launchchair setup codex` so the CLI writes native config and verifies MCP tool discovery. Use `launchchair bind --project-id PROJECT_UUID --cwd /absolute/path/to/verified-clone` after cloning when automatic discovery cannot locate the repo. Use `launchchair mcp-config codex --ui-fields` only for manual custom-form setup.
134
122
 
135
123
  ## What To Do First In A Fresh Chat
136
124
 
package/README.md CHANGED
@@ -49,59 +49,60 @@ Tokens are masked in CLI output. Do not paste real tokens into public docs, scre
49
49
 
50
50
  ## Guided Setup
51
51
 
52
- Use `setup` as the easiest path. It saves the build folder, writes a small `AGENTS.md` pointer into that folder, starts browser login when no token is saved yet, writes the MCP config for your agent, and then proves the server can complete MCP `initialize` and `tools/list` before reporting success.
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.
53
53
 
54
54
  Codex Desktop:
55
55
 
56
56
  ```bash
57
- launchchair setup codex --cwd /absolute/path/to/repo-or-empty-folder
57
+ launchchair setup codex
58
58
  ```
59
59
 
60
60
  If you prefer Codex Desktop's custom MCP form, copy paste-ready fields instead:
61
61
 
62
62
  ```bash
63
- launchchair setup codex --cwd /absolute/path/to/repo-or-empty-folder --copy-config
63
+ launchchair setup codex --copy-config
64
64
  ```
65
65
 
66
66
  Claude Desktop + Claude Code (Claude Code is registered at user scope when its CLI is installed):
67
67
 
68
68
  ```bash
69
- launchchair setup claude --cwd /absolute/path/to/repo-or-empty-folder
69
+ launchchair setup claude
70
70
  ```
71
71
 
72
72
  Configure only one Claude client when needed:
73
73
 
74
74
  ```bash
75
- launchchair setup claude-desktop --cwd /absolute/path/to/repo-or-empty-folder
76
- launchchair setup claude-code --cwd /absolute/path/to/repo-or-empty-folder
75
+ launchchair setup claude-desktop
76
+ launchchair setup claude-code
77
77
  ```
78
78
 
79
79
  Terminal / CLI agents:
80
80
 
81
81
  ```bash
82
- launchchair setup terminal --cwd /absolute/path/to/repo-or-empty-folder
82
+ launchchair setup terminal
83
83
  ```
84
84
 
85
85
  If you do not want browser login during setup, add `--skip-login`. You can also save an existing token during setup:
86
86
 
87
87
  ```bash
88
- launchchair setup codex --cwd . --skip-login
89
- launchchair setup codex --cwd . --token "<full lc_at token from LaunchChair Settings API Access>"
88
+ launchchair setup codex --skip-login
89
+ launchchair setup codex --token "<full lc_at token from LaunchChair Settings API Access>"
90
90
  ```
91
91
 
92
92
  Setup writes client configuration by default. Use `--copy-config`, `--ui-fields`, or `--print-config` only when you intentionally want manual configuration. Authenticated setup exits nonzero if the MCP handshake or required tool discovery fails; `--skip-mcp-check` is an explicit diagnostic escape hatch, not the recommended first-run path.
93
93
 
94
- For a fresh project before a repo exists, point `--cwd` at an empty folder. LaunchChair can run ideation, validation, positioning, pricing, and MVP blueprint before code exists; Stack Setup and Build MVP can later use that folder as the build target.
94
+ Fresh projects automatically use an isolated folder under `~/.launchchair/projects/<project-id>/workspace` before a repo exists. After Stack Setup, LaunchChair looks for the repo by its recorded Git URL in standard local repo folders and verifies the `origin` before switching. If it cannot find the clone, code work stops with an exact clone and `launchchair bind` instruction instead of using another project.
95
95
 
96
- Use `setup terminal` when the agent runs inside a normal shell instead of Codex Desktop or Claude Desktop MCP. It writes the same project-local `AGENTS.md` pointer, saves the build folder, and prints the CLI commands the agent should use: `launchchair projects`, `launchchair create`, `launchchair continue`, `launchchair run-once`, and `launchchair bridge`.
96
+ Use `setup terminal` when the agent runs inside a normal shell instead of Codex Desktop or Claude Desktop MCP. It installs the user-scoped bootstrap and prints the CLI commands the agent should use: `launchchair projects`, `launchchair create`, `launchchair continue`, `launchchair run-once`, and `launchchair bridge`.
97
97
 
98
98
  ## Common Commands
99
99
 
100
100
  ```bash
101
- launchchair setup codex --cwd /absolute/path/to/repo-or-empty-folder
102
- launchchair setup claude --cwd /absolute/path/to/repo-or-empty-folder
103
- launchchair setup claude-code --cwd /absolute/path/to/repo-or-empty-folder
104
- launchchair setup terminal --cwd /absolute/path/to/repo-or-empty-folder
101
+ launchchair setup codex
102
+ launchchair setup claude
103
+ launchchair setup claude-code
104
+ launchchair setup terminal
105
+ launchchair bind --project-id <project-id> --cwd /absolute/path/to/verified-clone
105
106
  launchchair projects
106
107
  launchchair projects --query LeafLore
107
108
  launchchair scorecards --project-id <project-id>
@@ -114,7 +115,7 @@ launchchair bridge
114
115
  launchchair mcp-config codex
115
116
  launchchair mcp-config codex --ui-fields --copy-config
116
117
  launchchair mcp-config claude
117
- launchchair install-agent-md --cwd /absolute/path/to/repo-or-empty-folder
118
+ launchchair install-agent-md --cwd /absolute/path/to/repo
118
119
  ```
119
120
 
120
121
  Feature projects can be created and planned from the CLI for every authenticated LaunchChair workspace:
@@ -175,17 +176,17 @@ launchchair run-once
175
176
 
176
177
  ## Runner Targets
177
178
 
178
- Codex:
179
+ Codex routes are selected globally, while repository paths are bound per LaunchChair project:
179
180
 
180
181
  ```bash
181
- launchchair config set codex-cwd /absolute/path/to/repo-or-empty-folder
182
+ launchchair bind --project-id <project-id> --cwd /absolute/path/to/verified-clone
182
183
  launchchair bridge
183
184
  ```
184
185
 
185
186
  Claude Code:
186
187
 
187
188
  ```bash
188
- launchchair config set claude-code-cwd /absolute/path/to/repo-or-empty-folder
189
+ launchchair bind --project-id <project-id> --cwd /absolute/path/to/verified-clone
189
190
  LC_AGENT_ROUTER_GPT_TARGET=claude_code LC_AGENT_ROUTER_CODEX_TARGET=claude_code launchchair bridge
190
191
  ```
191
192
 
@@ -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.10";
23
+ const VERSION = "0.4.12";
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()
@@ -179,9 +184,11 @@ Commands:
179
184
  bridge Start the long-running local bridge for Run with Agent
180
185
  mcp-server Run the stdio MCP server using saved CLI config
181
186
  setup codex|claude|claude-desktop|claude-code|terminal [options]
182
- Guided setup for CLI, CWD, AGENTS.md, MCP config, and bridge
187
+ Guided global CLI/MCP setup with isolated project workspaces
188
+ bind --project-id ID --cwd PATH Bind one LaunchChair project to a verified local Git clone
189
+ unbind --project-id ID Remove one project's saved local repository binding
183
190
  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
191
+ config set KEY VALUE Save base-url or project-id
185
192
  mcp-config codex|claude [options]
186
193
  Print MCP config or Codex custom MCP form fields
187
194
  install-agent-md [--cwd PATH] Create a small AGENTS.md pointer in a fresh repo
@@ -215,7 +222,7 @@ Continue options:
215
222
  --completion-target TEXT Natural-language target for the loop run
216
223
 
217
224
  Setup options:
218
- --cwd PATH Repo folder or empty folder the agent should build into
225
+ --cwd PATH Bind this repo only when --project-id is also supplied
219
226
  --token TOKEN Optional manual lc_at token fallback to save during setup
220
227
  --project-id ID Optional default LaunchChair project id
221
228
  --base-url URL Optional LaunchChair base URL
@@ -241,8 +248,8 @@ Env:
241
248
  LAUNCHCHAIR_AGENT_TOKEN Optional full lc_at token override
242
249
  LAUNCHCHAIR_BASE_URL Default: https://www.launchchair.io
243
250
  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
251
+ LC_AGENT_CODEX_CWD Legacy candidate only; verified per project before use
252
+ LC_AGENT_CLAUDE_CODE_CWD Legacy candidate only; verified per project before use
246
253
 
247
254
  MCP config options:
248
255
  --ui-fields Print fields for Codex Desktop's custom MCP form
@@ -767,9 +774,9 @@ function buildBridgeEnv(projectId = defaultProjectId()) {
767
774
  LAUNCHCHAIR_CONFIG_DIR: CONFIG_DIR,
768
775
  LAUNCHCHAIR_AGENT_TOKEN: token(),
769
776
  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
777
  };
778
+ delete childEnv.LC_AGENT_CODEX_CWD;
779
+ delete childEnv.LC_AGENT_CLAUDE_CODE_CWD;
773
780
  Object.keys(childEnv).forEach((key) => {
774
781
  if (childEnv[key] === undefined || childEnv[key] === null || childEnv[key] === "") {
775
782
  delete childEnv[key];
@@ -881,7 +888,6 @@ function mcpEnvForTarget(target, options = {}) {
881
888
  const includeTokenPlaceholder = options.includeTokenPlaceholder !== false;
882
889
  const details = targetDetails(target || "codex");
883
890
  const projectId = defaultProjectId();
884
- const cwd = configValue(details.cwdConfigKey, [details.cwdEnvName], "/absolute/path/to/repo-or-empty-folder");
885
891
  const envBlock = {
886
892
  LAUNCHCHAIR_BASE_URL: baseUrl(),
887
893
  LAUNCHCHAIR_CONFIG_DIR: CONFIG_DIR,
@@ -889,7 +895,6 @@ function mcpEnvForTarget(target, options = {}) {
889
895
  ...(projectId ? { LAUNCHCHAIR_PROJECT_ID: projectId } : {}),
890
896
  LC_AGENT_ROUTER_GPT_TARGET: details.routerTarget,
891
897
  LC_AGENT_ROUTER_CODEX_TARGET: details.routerTarget,
892
- [details.cwdEnvName]: cwd,
893
898
  };
894
899
  return Object.fromEntries(Object.entries(envBlock).filter(([, value]) => String(value ?? "").trim()));
895
900
  }
@@ -1025,13 +1030,12 @@ ${envLines}`;
1025
1030
 
1026
1031
  function renderMcpUiFields(target) {
1027
1032
  const normalized = normalizedMcpTarget(target);
1028
- const details = targetDetails(normalized);
1029
1033
  const server = mcpServerConfig(normalized);
1030
1034
  const envLines =
1031
1035
  Object.entries(server.env)
1032
1036
  .map(([key, value]) => ` ${key} = ${value}`)
1033
1037
  .join("\n") || " (none)";
1034
- const workingDir = server.env[details.cwdEnvName] || process.cwd();
1038
+ const workingDir = path.join(CONFIG_DIR, "projects");
1035
1039
  return `Name
1036
1040
  launchchair
1037
1041
 
@@ -1173,14 +1177,20 @@ function printMcpConfig(target, args = {}) {
1173
1177
 
1174
1178
  function showConfig() {
1175
1179
  const config = loadConfig();
1180
+ const bindings = listProjectWorkspaceBindings(CONFIG_PATH);
1176
1181
  title("CLI config");
1177
1182
  row("Path", CONFIG_PATH, existsSync(CONFIG_PATH) ? "dim" : "yellow");
1178
1183
  row("Base URL", baseUrl(), "cyan");
1179
1184
  row("Token", maskToken(token()), token() ? "green" : "yellow");
1180
1185
  row("Token source", tokenSource(), token() ? "green" : "yellow");
1181
1186
  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");
1187
+ row("Project bindings", Object.keys(bindings).length, Object.keys(bindings).length > 0 ? "cyan" : "dim");
1188
+ for (const [projectId, binding] of Object.entries(bindings)) {
1189
+ row(` ${projectId.slice(0, 12)}`, typeof binding === "string" ? binding : binding?.path ?? "invalid", "cyan");
1190
+ }
1191
+ if (config.codexCwd || config.claudeCodeCwd || env("LC_AGENT_CODEX_CWD") || env("LC_AGENT_CLAUDE_CODE_CWD")) {
1192
+ row("Legacy CWD", "ignored unless its Git origin matches the current project", "yellow");
1193
+ }
1184
1194
  }
1185
1195
 
1186
1196
  function setConfig(args) {
@@ -1189,16 +1199,50 @@ function setConfig(args) {
1189
1199
  const allowed = {
1190
1200
  "base-url": "baseUrl",
1191
1201
  "project-id": "projectId",
1192
- "codex-cwd": "codexCwd",
1193
- "claude-code-cwd": "claudeCodeCwd",
1194
1202
  };
1195
1203
  if (!key || !value || !allowed[key]) {
1196
- throw new Error("Usage: launchchair config set base-url|project-id|codex-cwd|claude-code-cwd VALUE");
1204
+ throw new Error("Usage: launchchair config set base-url|project-id VALUE. Use launchchair bind for local repositories.");
1197
1205
  }
1198
1206
  saveConfig({ [allowed[key]]: key === "base-url" ? value.replace(/\/+$/, "") : value });
1199
1207
  ok(`Saved ${key} to ${CONFIG_PATH}`);
1200
1208
  }
1201
1209
 
1210
+ async function getRunnerWorkspaceContext(projectId) {
1211
+ const payload = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/runner-workspace`);
1212
+ return payload?.workspace && typeof payload.workspace === "object" ? payload.workspace : { projectId };
1213
+ }
1214
+
1215
+ async function bindProject(args) {
1216
+ const projectId = String(args["project-id"] || defaultProjectId() || "").trim();
1217
+ const cwd = String(args.cwd || "").trim();
1218
+ if (!projectId || !cwd) {
1219
+ throw new Error("Usage: launchchair bind --project-id ID --cwd /absolute/path/to/repo");
1220
+ }
1221
+ const context = await getRunnerWorkspaceContext(projectId);
1222
+ const repository = context?.repository && typeof context.repository === "object" ? context.repository : {};
1223
+ const binding = bindProjectWorkspace({
1224
+ configPath: CONFIG_PATH,
1225
+ projectId,
1226
+ cwd,
1227
+ repositoryUrl: String(repository.repoUrl || repository.cloneUrl || "").trim(),
1228
+ });
1229
+ installAgentMd({ cwd: binding.path });
1230
+ title("project repository bound");
1231
+ row("Project ID", projectId, "cyan");
1232
+ row("Local repository", binding.path, "cyan");
1233
+ if (binding.repositoryUrl) row("Verified origin", binding.repositoryUrl, "green");
1234
+ ok("Future runs for this LaunchChair project will use this repository.");
1235
+ ok("Already-running bridges will pick up this binding on the next run.");
1236
+ }
1237
+
1238
+ function unbindProject(args) {
1239
+ const projectId = String(args["project-id"] || defaultProjectId() || "").trim();
1240
+ if (!projectId) throw new Error("Usage: launchchair unbind --project-id ID");
1241
+ const existed = unbindProjectWorkspace({ configPath: CONFIG_PATH, projectId });
1242
+ if (existed) ok(`Removed local repository binding for ${projectId}.`);
1243
+ else warn(`No local repository binding existed for ${projectId}.`);
1244
+ }
1245
+
1202
1246
  function installAgentMd(args) {
1203
1247
  const cwd = path.resolve(String(args.cwd || process.cwd()));
1204
1248
  const target = path.join(cwd, "AGENTS.md");
@@ -1258,17 +1302,23 @@ function installLaunchChairSkill(args) {
1258
1302
 
1259
1303
  async function setup(args) {
1260
1304
  const details = targetDetails(args._[0] || "codex");
1261
- const cwd = path.resolve(String(args.cwd || process.cwd()));
1305
+ const requestedCwd = typeof args.cwd === "string" && args.cwd.trim() ? path.resolve(args.cwd.trim()) : "";
1262
1306
  const writeConfig =
1263
1307
  args["write-config"] === true ||
1264
1308
  (args["copy-config"] !== true && args["ui-fields"] !== true && args["print-config"] !== true);
1265
1309
  const incomingToken = typeof args.token === "string" ? args.token.trim() : "";
1266
1310
  const incomingBaseUrl = typeof args["base-url"] === "string" ? args["base-url"].trim().replace(/\/+$/, "") : undefined;
1267
1311
  const incomingProjectId = typeof args["project-id"] === "string" ? args["project-id"].trim() : "";
1312
+ const bindingProjectId = incomingProjectId || defaultProjectId();
1313
+
1314
+ if (requestedCwd && !bindingProjectId) {
1315
+ throw new Error("--cwd must be paired with --project-id. Global runner directories are no longer supported.");
1316
+ }
1268
1317
 
1269
1318
  title(`guided setup for ${details.label}`);
1270
- mkdirSync(cwd, { recursive: true });
1271
- ok(`Build folder ready: ${cwd}`);
1319
+ const projectWorkspaceRoot = path.join(CONFIG_DIR, "projects");
1320
+ mkdirSync(projectWorkspaceRoot, { recursive: true, mode: 0o700 });
1321
+ ok(`Isolated project workspace root ready: ${projectWorkspaceRoot}`);
1272
1322
 
1273
1323
  if (incomingToken) {
1274
1324
  if (!incomingToken.startsWith("lc_at_")) {
@@ -1287,10 +1337,6 @@ async function setup(args) {
1287
1337
  });
1288
1338
  }
1289
1339
 
1290
- saveConfig({ [details.cwdConfigKey]: cwd });
1291
- ok(`Saved ${details.cwdEnvName}: ${cwd}`);
1292
- installAgentMd({ cwd });
1293
-
1294
1340
  if (!token() && args["skip-login"] !== true) {
1295
1341
  print("");
1296
1342
  warn("No saved LaunchChair token found. Starting browser approval now.");
@@ -1306,8 +1352,12 @@ async function setup(args) {
1306
1352
  });
1307
1353
  }
1308
1354
 
1355
+ if (requestedCwd) {
1356
+ await bindProject({ "project-id": bindingProjectId, cwd: requestedCwd });
1357
+ }
1358
+
1309
1359
  if (details.mcpTarget === "codex") {
1310
- installLaunchChairSkill({ cwd });
1360
+ installLaunchChairSkill({ cwd: path.dirname(CONFIG_DIR) });
1311
1361
  }
1312
1362
 
1313
1363
  print("");
@@ -1410,7 +1460,7 @@ async function setup(args) {
1410
1460
  print("Start a fresh agent chat. The agent should discover the LaunchChair MCP tools automatically.");
1411
1461
  }
1412
1462
  print("");
1413
- row("Build folder", cwd, "cyan");
1463
+ row("Workspace mode", requestedCwd ? `project-bound: ${requestedCwd}` : "isolated per LaunchChair project", "cyan");
1414
1464
  row("Runner target", details.routerTarget, "cyan");
1415
1465
  row("Config file", CONFIG_PATH, "dim");
1416
1466
 
@@ -1441,6 +1491,8 @@ async function main() {
1441
1491
  if (command === "bridge") return spawnBridge(["--verbose"]);
1442
1492
  if (command === "mcp-server") return spawnMcpServer();
1443
1493
  if (command === "setup") return setup(args);
1494
+ if (command === "bind") return bindProject(args);
1495
+ if (command === "unbind") return unbindProject(args);
1444
1496
  if (command === "config" && args._[0] === "set") return setConfig({ ...args, _: args._.slice(1) });
1445
1497
  if (command === "config") return showConfig();
1446
1498
  if (command === "mcp-config") return printMcpConfig(args._[0] || "codex", args);
package/local-bridge.mjs CHANGED
@@ -15,9 +15,10 @@ import os from "node:os";
15
15
  import path from "node:path";
16
16
  import process from "node:process";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { resolveProjectWorkspace } from "./project-workspace.mjs";
18
19
  import { buildRunnerCommand, parseRunnerCommand } from "./runner-command.mjs";
19
20
 
20
- const VERSION = "0.4.10";
21
+ const VERSION = "0.4.12";
21
22
  const CLIENT_TYPES = ["codex", "chatgpt", "claude", "claude_code", "other"];
22
23
  const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
23
24
  const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
@@ -130,10 +131,10 @@ Optional env:
130
131
  AGENT_MOCK_RESPONSE If set, skips runner command and returns this payload
131
132
  OPENAI_API_KEY Enables built-in ChatGPT runner fallback
132
133
  ANTHROPIC_API_KEY Enables built-in Claude runner fallback
133
- LC_AGENT_CODEX_CWD Folder codex-runner should build in (default: bridge cwd)
134
+ LC_AGENT_CODEX_CWD Legacy search candidate; Git origin must match the project
134
135
  LC_AGENT_CODEX_SANDBOX read-only|workspace-write|danger-full-access (default: workspace-write)
135
136
  LC_AGENT_CODEX_MODEL Optional Codex model override
136
- LC_AGENT_CLAUDE_CODE_CWD Folder claude-code-runner should build in (default: bridge cwd)
137
+ LC_AGENT_CLAUDE_CODE_CWD Legacy search candidate; Git origin must match the project
137
138
  LC_AGENT_CLAUDE_CODE_MODEL Optional Claude Code model override
138
139
  LC_AGENT_CLAUDE_CODE_ALLOWED_TOOLS
139
140
  Optional Claude Code allowedTools list
@@ -193,6 +194,10 @@ const config = {
193
194
  gpt: normalizeTarget(env("LC_AGENT_ROUTER_GPT_TARGET", "codex"), "codex"),
194
195
  codex: normalizeTarget(env("LC_AGENT_ROUTER_CODEX_TARGET", "codex"), "codex"),
195
196
  },
197
+ legacyRepoPaths: {
198
+ codex: env("LC_AGENT_CODEX_CWD", savedConfigValue("codexCwd", process.cwd())),
199
+ claudeCode: env("LC_AGENT_CLAUDE_CODE_CWD", savedConfigValue("claudeCodeCwd", process.cwd())),
200
+ },
196
201
  runnerCommands: {
197
202
  generic: env("LC_AGENT_RUNNER_CMD"),
198
203
  gpt: env("LC_AGENT_RUNNER_CMD_GPT"),
@@ -347,6 +352,25 @@ async function claimNextRun() {
347
352
  return payload?.run ?? null;
348
353
  }
349
354
 
355
+ function resolveWorkspaceForRun(run) {
356
+ const target = getTargetForRun(run) === "claude_code" ? "claude_code" : "codex";
357
+ const legacyPath = target === "claude_code" ? config.legacyRepoPaths.claudeCode : config.legacyRepoPaths.codex;
358
+ const context = run?.runnerWorkspace && typeof run.runnerWorkspace === "object" && !Array.isArray(run.runnerWorkspace)
359
+ ? run.runnerWorkspace
360
+ : {};
361
+ return {
362
+ target,
363
+ workspace: resolveProjectWorkspace({
364
+ configDir: CONFIG_DIR,
365
+ configPath: CONFIG_PATH,
366
+ projectId: String(run?.projectId ?? context.projectId ?? "").trim(),
367
+ repository: context.repository,
368
+ requireRepository: context.requiresRepository === true,
369
+ legacyPaths: [legacyPath],
370
+ }),
371
+ };
372
+ }
373
+
350
374
  async function executeRunner(run) {
351
375
  if (config.mockResponse) {
352
376
  return {
@@ -367,6 +391,9 @@ async function executeRunner(run) {
367
391
  }
368
392
  const command = buildRunnerCommand(commandTemplate, run);
369
393
  const parsedCommand = parseRunnerCommand(command);
394
+ const resolved = resolveWorkspaceForRun(run);
395
+ const workspaceEnvName = resolved.target === "claude_code" ? "LC_AGENT_CLAUDE_CODE_CWD" : "LC_AGENT_CODEX_CWD";
396
+ log(`Workspace ${resolved.workspace.path} (${resolved.workspace.source})`);
370
397
 
371
398
  const startedAt = Date.now();
372
399
  return new Promise((resolve, reject) => {
@@ -382,6 +409,7 @@ async function executeRunner(run) {
382
409
  LC_AGENT_CARD_ID: String(run.cardId ?? ""),
383
410
  LC_AGENT_PROMPT_MODE: String(run.promptMode ?? ""),
384
411
  LC_AGENT_RUN_TYPE: String(run.runType ?? ""),
412
+ [workspaceEnvName]: resolved.workspace.path,
385
413
  },
386
414
  });
387
415
 
@@ -415,6 +443,7 @@ async function executeRunner(run) {
415
443
  durationMs,
416
444
  usedMock: false,
417
445
  target: getTargetForRun(run),
446
+ workspace: resolved.workspace,
418
447
  });
419
448
  });
420
449
 
@@ -480,6 +509,7 @@ async function runOnce() {
480
509
  runIntent: run.runIntent ?? "initial",
481
510
  attemptNumber: run.attemptNumber ?? 1,
482
511
  maxAttempts: run.maxAttempts ?? 1,
512
+ localWorkspaceSource: execution.workspace?.source ?? null,
483
513
  },
484
514
  error: null,
485
515
  });
@@ -17,9 +17,10 @@ import os from "node:os";
17
17
  import path from "node:path";
18
18
  import process from "node:process";
19
19
  import { fileURLToPath } from "node:url";
20
+ import { resolveProjectWorkspace } from "./project-workspace.mjs";
20
21
  import { buildRunnerCommand, parseRunnerCommand } from "./runner-command.mjs";
21
22
 
22
- const VERSION = "0.4.10";
23
+ const VERSION = "0.4.12";
23
24
  const SERVER_NAME = "launchchair-agent-mcp";
24
25
  const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
25
26
  const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
@@ -27,7 +28,7 @@ const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
27
28
  : path.join(os.homedir() || process.cwd(), ".launchchair");
28
29
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
29
30
  const BASE_SERVER_INSTRUCTIONS =
30
- "Use LaunchChair MCP tools—not web browsing—for LaunchChair project discovery, creation, planning, validation scoring, and loops. Call the most specific available tool. When asked for validation scores, call launchchair_get_scorecards and report those scorecards; do not substitute generated direction-option scores. If a requested workflow is unavailable, explain the access or setup issue instead of silently substituting another project type or workflow.";
31
+ "Use LaunchChair MCP tools—not web browsing—for LaunchChair project discovery, creation, planning, validation scoring, and loops. Call the most specific available tool. When asked for validation scores, call launchchair_get_scorecards and report those scorecards; do not substitute generated direction-option scores. Always present completedPhaseLinks, selected choice URLs, and any upgradeUrl returned by a tool to the user as clickable links. If a requested workflow is unavailable, explain the access or setup issue instead of silently substituting another project type or workflow.";
31
32
 
32
33
  function nodeScriptCommand(scriptName) {
33
34
  return `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(AGENT_SCRIPT_DIR, scriptName))}`;
@@ -128,7 +129,7 @@ const config = {
128
129
  codexTarget: env("LC_AGENT_RUNNER_CMD_CODEX", builtInRunnerCommand("codex")),
129
130
  claudeCode: env("LC_AGENT_RUNNER_CMD_CLAUDE_CODE", builtInRunnerCommand("claude_code")),
130
131
  },
131
- localRepoPaths: {
132
+ legacyRepoPaths: {
132
133
  codex: env("LC_AGENT_CODEX_CWD", savedConfigValue("codexCwd", process.cwd())),
133
134
  claudeCode: env("LC_AGENT_CLAUDE_CODE_CWD", savedConfigValue("claudeCodeCwd", process.cwd())),
134
135
  },
@@ -227,6 +228,32 @@ function commandForRun(run) {
227
228
  return config.runnerCommands.generic;
228
229
  }
229
230
 
231
+ function runnerTargetForRun(run) {
232
+ return targetForRun(run) === "claude_code" ? "claude_code" : "codex";
233
+ }
234
+
235
+ function resolveWorkspaceForRun(run, workspaceContext = run?.runnerWorkspace, options = {}) {
236
+ const target = runnerTargetForRun(run);
237
+ const context = workspaceContext && typeof workspaceContext === "object" && !Array.isArray(workspaceContext)
238
+ ? workspaceContext
239
+ : {};
240
+ const legacyPath = target === "claude_code" ? config.legacyRepoPaths.claudeCode : config.legacyRepoPaths.codex;
241
+ return resolveProjectWorkspace({
242
+ configDir: CONFIG_DIR,
243
+ configPath: CONFIG_PATH,
244
+ projectId: String(run?.projectId ?? context.projectId ?? "").trim(),
245
+ repository: context.repository,
246
+ requireRepository: context.requiresRepository === true,
247
+ preferRepository: options.preferRepository === true,
248
+ legacyPaths: [legacyPath],
249
+ });
250
+ }
251
+
252
+ async function getRunnerWorkspaceContext(projectId) {
253
+ const payload = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/runner-workspace`);
254
+ return payload?.workspace && typeof payload.workspace === "object" ? payload.workspace : { projectId };
255
+ }
256
+
230
257
  async function executeRunner(run) {
231
258
  if (config.mockResponse) {
232
259
  return {
@@ -245,6 +272,10 @@ async function executeRunner(run) {
245
272
  }
246
273
  const command = buildRunnerCommand(template, run);
247
274
  const parsedCommand = parseRunnerCommand(command);
275
+ const workspace = resolveWorkspaceForRun(run);
276
+ const workspaceEnvName = runnerTargetForRun(run) === "claude_code"
277
+ ? "LC_AGENT_CLAUDE_CODE_CWD"
278
+ : "LC_AGENT_CODEX_CWD";
248
279
  const startedAt = Date.now();
249
280
  return new Promise((resolve, reject) => {
250
281
  const child = spawn(parsedCommand.file, parsedCommand.args, {
@@ -259,6 +290,7 @@ async function executeRunner(run) {
259
290
  LC_AGENT_CARD_ID: String(run.cardId ?? ""),
260
291
  LC_AGENT_PROMPT_MODE: String(run.promptMode ?? ""),
261
292
  LC_AGENT_RUN_TYPE: String(run.runType ?? ""),
293
+ [workspaceEnvName]: workspace.path,
262
294
  },
263
295
  });
264
296
  let stdout = "";
@@ -286,6 +318,7 @@ async function executeRunner(run) {
286
318
  stderr,
287
319
  exitCode: code ?? 0,
288
320
  durationMs,
321
+ workspace,
289
322
  });
290
323
  });
291
324
  child.stdin.write(String(run.prompt ?? ""));
@@ -366,6 +399,7 @@ async function runOnce(args = {}) {
366
399
  runIntent: run.runIntent ?? "initial",
367
400
  attemptNumber: run.attemptNumber ?? 1,
368
401
  maxAttempts: run.maxAttempts ?? 1,
402
+ localWorkspaceSource: execution.workspace?.source ?? null,
369
403
  },
370
404
  error: null,
371
405
  clientName: String(args.clientName ?? "").trim() || config.clientName,
@@ -520,22 +554,35 @@ async function continueProjectLoop(args = {}) {
520
554
  typeof args.ideaSnapshot === "object" && args.ideaSnapshot !== null && !Array.isArray(args.ideaSnapshot)
521
555
  ? args.ideaSnapshot
522
556
  : null;
523
- const runnerTarget = config.modeTargets.codex === "claude_code" ? "claude_code" : config.modeTargets.codex;
524
- const localRepoPath =
525
- runnerTarget === "claude_code" ? config.localRepoPaths.claudeCode : config.localRepoPaths.codex;
526
- const agentSetup = {
527
- localRepoPath,
528
- runnerTarget,
529
- clientType: config.clientType,
530
- ...(typeof args.agentSetup === "object" && args.agentSetup !== null && !Array.isArray(args.agentSetup)
531
- ? args.agentSetup
532
- : {}),
557
+ const runnerTarget = config.modeTargets.codex === "claude_code" ? "claude_code" : "codex";
558
+ let runnerWorkspaceContext = await getRunnerWorkspaceContext(projectId);
559
+ let agentSetup = null;
560
+ const refreshAgentSetup = async () => {
561
+ runnerWorkspaceContext = await getRunnerWorkspaceContext(projectId);
562
+ const resolvedAgentWorkspace = resolveWorkspaceForRun(
563
+ { projectId, promptMode: "codex", runnerWorkspace: runnerWorkspaceContext },
564
+ runnerWorkspaceContext,
565
+ { preferRepository: true },
566
+ );
567
+ agentSetup = {
568
+ ...(typeof args.agentSetup === "object" && args.agentSetup !== null && !Array.isArray(args.agentSetup)
569
+ ? args.agentSetup
570
+ : {}),
571
+ localRepoPath: resolvedAgentWorkspace.path,
572
+ runnerTarget,
573
+ clientType: config.clientType,
574
+ repositoryVerified: resolvedAgentWorkspace.repositoryVerified,
575
+ repositoryUrl: resolvedAgentWorkspace.repositoryUrl,
576
+ workspaceSource: resolvedAgentWorkspace.source,
577
+ };
533
578
  };
579
+ await refreshAgentSetup();
534
580
  let pendingChoiceSelections = normalizeChoiceSelections(args);
535
581
  const steps = [];
536
582
  let final = null;
537
583
 
538
584
  for (let index = 0; index < maxSteps; index += 1) {
585
+ if (index > 0) await refreshAgentSetup();
539
586
  const loop = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/loop/continue`, {
540
587
  method: "POST",
541
588
  body: {
@@ -553,7 +600,9 @@ async function continueProjectLoop(args = {}) {
553
600
  break;
554
601
  }
555
602
 
556
- const run = loop.run;
603
+ const run = loop.run
604
+ ? { ...loop.run, runnerWorkspace: loop.runnerWorkspace ?? runnerWorkspaceContext }
605
+ : null;
557
606
  if (!run?.id || !run?.prompt) {
558
607
  final = loop;
559
608
  break;
@@ -578,6 +627,7 @@ async function continueProjectLoop(args = {}) {
578
627
  attemptNumber: run.attemptNumber ?? 1,
579
628
  maxAttempts: run.maxAttempts ?? 1,
580
629
  projectLoopStep: index + 1,
630
+ localWorkspaceSource: execution.workspace?.source ?? null,
581
631
  },
582
632
  error: null,
583
633
  clientName: String(args.clientName ?? "").trim() || config.clientName,
@@ -1096,6 +1146,9 @@ async function handleRpc(request) {
1096
1146
  }
1097
1147
  writeError(id, -32601, `Unknown tool: ${toolName}`);
1098
1148
  } catch (error) {
1149
+ const errorPayload = error?.payload && typeof error.payload === "object" && !Array.isArray(error.payload)
1150
+ ? error.payload
1151
+ : {};
1099
1152
  writeResult(id, {
1100
1153
  content: [
1101
1154
  {
@@ -1103,6 +1156,11 @@ async function handleRpc(request) {
1103
1156
  text: JSON.stringify(
1104
1157
  {
1105
1158
  error: error instanceof Error ? error.message : String(error ?? "Tool failed"),
1159
+ ...errorPayload,
1160
+ ...(Number.isFinite(error?.status) ? { httpStatus: error.status } : {}),
1161
+ ...(Number.isFinite(error?.retryAfterSeconds)
1162
+ ? { retryAfterSeconds: error.retryAfterSeconds }
1163
+ : {}),
1106
1164
  },
1107
1165
  null,
1108
1166
  2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchchair/agent",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
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
+ "project-workspace.mjs",
18
19
  "runner-command.mjs",
19
20
  "codex-runner.mjs",
20
21
  "claude-code-runner.mjs",
@@ -0,0 +1,306 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ function asRecord(value) {
15
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
16
+ }
17
+
18
+ function text(value) {
19
+ return typeof value === "string" ? value.trim() : "";
20
+ }
21
+
22
+ function readConfig(configPath) {
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
25
+ return asRecord(parsed);
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeConfig(configPath, next) {
32
+ const directory = path.dirname(configPath);
33
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
34
+ const temporary = path.join(directory, `.${path.basename(configPath)}.${process.pid}.${Date.now()}.tmp`);
35
+ try {
36
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
37
+ renameSync(temporary, configPath);
38
+ try {
39
+ chmodSync(configPath, 0o600);
40
+ } catch {
41
+ // Best effort for filesystems without POSIX permissions.
42
+ }
43
+ } finally {
44
+ if (existsSync(temporary)) unlinkSync(temporary);
45
+ }
46
+ }
47
+
48
+ function safeProjectKey(projectId) {
49
+ const value = text(projectId);
50
+ if (!value) throw new Error("LaunchChair projectId is required to resolve a local workspace.");
51
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160);
52
+ }
53
+
54
+ export function normalizeGitRemote(value) {
55
+ let remote = text(value);
56
+ if (!remote) return "";
57
+ if (remote.startsWith("git@")) {
58
+ const match = remote.match(/^git@([^:]+):(.+)$/);
59
+ if (match) remote = `https://${match[1]}/${match[2]}`;
60
+ } else if (remote.startsWith("ssh://git@")) {
61
+ remote = remote.replace(/^ssh:\/\/git@/, "https://");
62
+ }
63
+ try {
64
+ const parsed = new URL(remote);
65
+ const pathname = parsed.pathname.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
66
+ return `${parsed.hostname.toLowerCase()}/${pathname.toLowerCase()}`;
67
+ } catch {
68
+ return remote.replace(/\.git$/i, "").replace(/\/+$/, "").toLowerCase();
69
+ }
70
+ }
71
+
72
+ export function repositoryNameFromUrl(value) {
73
+ const normalized = normalizeGitRemote(value);
74
+ return normalized.split("/").filter(Boolean).at(-1) ?? "";
75
+ }
76
+
77
+ function gitDirectory(repoPath) {
78
+ const dotGit = path.join(repoPath, ".git");
79
+ if (!existsSync(dotGit)) return "";
80
+ try {
81
+ if (statSync(dotGit).isDirectory()) return dotGit;
82
+ const pointer = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/im)?.[1]?.trim();
83
+ if (!pointer) return "";
84
+ return path.resolve(repoPath, pointer);
85
+ } catch {
86
+ return "";
87
+ }
88
+ }
89
+
90
+ export function readGitOrigin(repoPath) {
91
+ const gitDir = gitDirectory(repoPath);
92
+ if (!gitDir) return "";
93
+ try {
94
+ const raw = readFileSync(path.join(gitDir, "config"), "utf8");
95
+ const remoteBlock = raw.match(/\[remote\s+"origin"\]([\s\S]*?)(?=\n\[|$)/i)?.[1] ?? "";
96
+ return remoteBlock.match(/^\s*url\s*=\s*(.+)$/im)?.[1]?.trim() ?? "";
97
+ } catch {
98
+ return "";
99
+ }
100
+ }
101
+
102
+ function projectBindings(config) {
103
+ return asRecord(config.projectWorkspaces);
104
+ }
105
+
106
+ function bindingPath(config, projectId) {
107
+ const value = projectBindings(config)[projectId];
108
+ if (typeof value === "string") return text(value);
109
+ return text(asRecord(value).path);
110
+ }
111
+
112
+ function repositoryMatches(repoPath, expectedRepositoryUrl) {
113
+ const origin = readGitOrigin(repoPath);
114
+ if (!origin) return false;
115
+ const expected = normalizeGitRemote(expectedRepositoryUrl);
116
+ return !expected || normalizeGitRemote(origin) === expected;
117
+ }
118
+
119
+ function standardSearchRoots(config, legacyPaths = [], extraRoots = []) {
120
+ const home = os.homedir();
121
+ const configuredRoots = Array.isArray(config.repoSearchRoots) ? config.repoSearchRoots.map(text) : [];
122
+ const bindingParents = Object.values(projectBindings(config))
123
+ .map((value) => (typeof value === "string" ? value : asRecord(value).path))
124
+ .map(text)
125
+ .filter(Boolean)
126
+ .map((value) => path.dirname(path.resolve(value)));
127
+ const legacyParents = legacyPaths.map(text).filter(Boolean).map((value) => path.dirname(path.resolve(value)));
128
+ const legacyRoots = legacyPaths.map(text).filter(Boolean).map((value) => path.resolve(value));
129
+ return Array.from(
130
+ new Set(
131
+ [
132
+ ...extraRoots,
133
+ ...configuredRoots,
134
+ ...bindingParents,
135
+ ...legacyRoots,
136
+ ...legacyParents,
137
+ home ? path.join(home, "Documents", "GitHub") : "",
138
+ home ? path.join(home, "Developer") : "",
139
+ home ? path.join(home, "Projects") : "",
140
+ home ? path.join(home, "repos") : "",
141
+ ]
142
+ .map(text)
143
+ .filter(Boolean)
144
+ .map((value) => path.resolve(value)),
145
+ ),
146
+ );
147
+ }
148
+
149
+ function saveBinding(configPath, projectId, repoPath, repositoryUrl, source) {
150
+ const config = readConfig(configPath);
151
+ const bindings = projectBindings(config);
152
+ const next = {
153
+ ...config,
154
+ projectWorkspaces: {
155
+ ...bindings,
156
+ [projectId]: {
157
+ path: path.resolve(repoPath),
158
+ repositoryUrl: text(repositoryUrl) || text(asRecord(bindings[projectId]).repositoryUrl) || undefined,
159
+ source,
160
+ updatedAt: new Date().toISOString(),
161
+ },
162
+ },
163
+ };
164
+ writeConfig(configPath, next);
165
+ return next.projectWorkspaces[projectId];
166
+ }
167
+
168
+ export function bindProjectWorkspace({ configPath, projectId, cwd, repositoryUrl = "", source = "explicit" }) {
169
+ const id = text(projectId);
170
+ safeProjectKey(id);
171
+ const resolved = path.resolve(text(cwd));
172
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
173
+ throw new Error(`Local project directory does not exist: ${resolved}`);
174
+ }
175
+ const expected = text(repositoryUrl);
176
+ const origin = readGitOrigin(resolved);
177
+ if (expected && !origin) {
178
+ throw new Error(`The selected directory is not a Git clone. Expected ${expected}.`);
179
+ }
180
+ if (expected && normalizeGitRemote(origin) !== normalizeGitRemote(expected)) {
181
+ throw new Error(`Git origin mismatch. Expected ${expected}, found ${origin || "no origin"}.`);
182
+ }
183
+ return saveBinding(configPath, id, resolved, expected || origin, source);
184
+ }
185
+
186
+ export function unbindProjectWorkspace({ configPath, projectId }) {
187
+ const id = text(projectId);
188
+ safeProjectKey(id);
189
+ const config = readConfig(configPath);
190
+ const bindings = { ...projectBindings(config) };
191
+ const existed = Object.prototype.hasOwnProperty.call(bindings, id);
192
+ delete bindings[id];
193
+ writeConfig(configPath, { ...config, projectWorkspaces: bindings });
194
+ return existed;
195
+ }
196
+
197
+ export function listProjectWorkspaceBindings(configPath) {
198
+ return projectBindings(readConfig(configPath));
199
+ }
200
+
201
+ export function isolatedProjectWorkspace(configDir, projectId) {
202
+ const key = safeProjectKey(projectId);
203
+ const target = path.join(configDir, "projects", key, "workspace");
204
+ mkdirSync(target, { recursive: true, mode: 0o700 });
205
+ const instructionsPath = path.join(target, "AGENTS.md");
206
+ if (!existsSync(instructionsPath)) {
207
+ writeFileSync(
208
+ instructionsPath,
209
+ [
210
+ "# LaunchChair isolated project workspace",
211
+ "",
212
+ `This folder belongs only to LaunchChair project ${projectId}.`,
213
+ "Use the LaunchChair prompt and MCP/API context for pre-code work.",
214
+ "Do not inspect, edit, or execute against unrelated repositories.",
215
+ "Before code-changing work, bind a verified local Git clone with `launchchair bind --project-id <id> --cwd <path>`.",
216
+ "",
217
+ ].join("\n"),
218
+ { mode: 0o600 },
219
+ );
220
+ }
221
+ return target;
222
+ }
223
+
224
+ export function resolveProjectWorkspace({
225
+ configDir,
226
+ configPath,
227
+ projectId,
228
+ repository = {},
229
+ requireRepository = false,
230
+ preferRepository = false,
231
+ legacyPaths = [],
232
+ searchRoots = [],
233
+ }) {
234
+ const id = text(projectId);
235
+ safeProjectKey(id);
236
+ const config = readConfig(configPath);
237
+ const repo = asRecord(repository);
238
+ const repositoryUrl = text(repo.repoUrl) || text(repo.cloneUrl);
239
+ const cloneUrl = text(repo.cloneUrl) || repositoryUrl;
240
+ const repoName = text(repo.repoName) || repositoryNameFromUrl(repositoryUrl);
241
+ const boundPath = bindingPath(config, id);
242
+ const scratchPath = isolatedProjectWorkspace(configDir, id);
243
+
244
+ if (!requireRepository && !preferRepository) {
245
+ return {
246
+ path: scratchPath,
247
+ source: "isolated",
248
+ repositoryVerified: false,
249
+ repositoryUrl,
250
+ cloneUrl,
251
+ };
252
+ }
253
+
254
+ if (!repositoryUrl) {
255
+ if (requireRepository) {
256
+ throw new Error(
257
+ `LaunchChair project ${id} does not have repository metadata yet. Finish Stack Setup before running code-changing work.`,
258
+ );
259
+ }
260
+ return {
261
+ path: scratchPath,
262
+ source: "isolated",
263
+ repositoryVerified: false,
264
+ repositoryUrl: "",
265
+ cloneUrl: "",
266
+ };
267
+ }
268
+
269
+ const candidates = [boundPath, scratchPath, ...legacyPaths.map(text).filter(Boolean)];
270
+
271
+ if (repoName) {
272
+ for (const root of standardSearchRoots(config, legacyPaths, searchRoots)) {
273
+ candidates.push(path.join(root, repoName));
274
+ }
275
+ }
276
+
277
+ for (const candidate of Array.from(new Set(candidates.filter(Boolean).map((value) => path.resolve(value))))) {
278
+ if (!existsSync(candidate)) continue;
279
+ if (!repositoryMatches(candidate, repositoryUrl)) continue;
280
+ const source = candidate === boundPath ? "binding" : "auto_detected";
281
+ if (candidate !== boundPath) saveBinding(configPath, id, candidate, repositoryUrl || readGitOrigin(candidate), source);
282
+ return {
283
+ path: candidate,
284
+ source,
285
+ repositoryVerified: true,
286
+ repositoryUrl: repositoryUrl || readGitOrigin(candidate),
287
+ cloneUrl,
288
+ };
289
+ }
290
+
291
+ if (requireRepository) {
292
+ const expected = repositoryUrl ? ` for ${repositoryUrl}` : "";
293
+ const cloneStep = cloneUrl && repoName ? ` Clone it with: git clone ${cloneUrl} \"${repoName}\".` : "";
294
+ throw new Error(
295
+ `No verified local Git clone is bound to LaunchChair project ${id}${expected}.${cloneStep} Then run: launchchair bind --project-id ${id} --cwd \"/absolute/path/to/${repoName || "repo"}\"`,
296
+ );
297
+ }
298
+
299
+ return {
300
+ path: scratchPath,
301
+ source: "isolated",
302
+ repositoryVerified: false,
303
+ repositoryUrl,
304
+ cloneUrl,
305
+ };
306
+ }
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.10",
6
+ "version": "0.4.12",
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.10",
12
+ "version": "0.4.12",
13
13
  "runtimeHint": "npx",
14
14
  "runtimeArguments": [
15
15
  {