worktree-bay 4.0.1 → 4.0.3

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.
@@ -1,19 +1,20 @@
1
1
  import { scanOccupancy, readLabels } from '../slots.js';
2
2
  import { portOf } from '../ports.js';
3
3
  import { log } from '../util/log.js';
4
- import { recordedFor, pidOnPort } from '../proc.js';
4
+ import { pidOnPort } from '../proc.js';
5
5
  import { color as c } from '../util/color.js';
6
6
  import { t } from '../i18n.js';
7
- // 该服务的约定端口是否有进程在监听(按端口判,不受 shell/pnpm 让记录 pid 漂移的影响)
8
- function running(cfg, dir, port) { return !!recordedFor(cfg.workspaceRoot, dir) && !!pidOnPort(port); }
7
+ // 「在跑」= 该服务约定端口上有进程监听,覆盖三类:docker(setup) / managed dev server(start) / 外部手起。
8
+ // 只按端口判:不依赖进程账本 dir 精确匹配(dir 相对/绝对形态会漂移),docker 服务也无 managed 记录。
9
+ function running(port) { return !!pidOnPort(port); }
9
10
  export function renderSlots(cfg) {
10
11
  const occ = scanOccupancy(cfg);
11
12
  const labels = readLabels(cfg);
12
13
  const slots = new Set([...occ.keys(), ...Object.keys(labels).map(Number)]);
13
14
  const lines = [];
14
15
  for (const n of [...slots].sort((a, b) => a - b)) {
15
- const svc = (occ.get(n) ?? []).map((o) => { const p = portOf(cfg.services[o.service].port, n); return `${o.service}@${p}${running(cfg, o.dir, p) ? c.green(' ▸run') : ''}`; });
16
- lines.push(`${c.dim('slot ' + n)} ${c.bold(c.cyan(labels[String(n)] ?? t('(未命名)', '(unnamed)')))} [${svc.join(', ') || c.dim(t('无 worktree', 'no worktree'))}]`);
16
+ const svc = (occ.get(n) ?? []).map((o) => { const p = portOf(cfg.services[o.service].port, n); const dot = running(p) ? c.green('') : c.dim(''); return `${dot}${o.service}@${p}`; });
17
+ lines.push(`${c.bold(c.cyan(String(n)))}${c.dim(':')} ${c.bold(labels[String(n)] ?? t('(未命名)', '(unnamed)'))} [${svc.join(', ') || c.dim(t('无 worktree', 'no worktree'))}]`);
17
18
  }
18
19
  return lines.join('\n') || t('(无槽位在用)', '(no slots in use)');
19
20
  }
@@ -23,7 +24,7 @@ export function slotsData(cfg) {
23
24
  const slots = new Set([...occ.keys(), ...Object.keys(labels).map(Number)]);
24
25
  return [...slots].sort((a, b) => a - b).map((n) => ({
25
26
  slot: n, feature: labels[String(n)] ?? null,
26
- services: (occ.get(n) ?? []).map((o) => { const p = portOf(cfg.services[o.service].port, n); return { service: o.service, port: p, dir: o.dir, running: running(cfg, o.dir, p) }; }),
27
+ services: (occ.get(n) ?? []).map((o) => { const p = portOf(cfg.services[o.service].port, n); return { service: o.service, port: p, dir: o.dir, running: running(p) }; }),
27
28
  }));
28
29
  }
29
30
  export function lsCommand(cfg, json = false) { log(json ? JSON.stringify(slotsData(cfg), null, 2) : renderSlots(cfg)); }
package/dist/config.js CHANGED
@@ -4,16 +4,19 @@ import { t } from './i18n.js';
4
4
  function refs(tpl) { return [...tpl.matchAll(/\{(\w+)\}/g)].map((m) => m[1]); }
5
5
  export function parseConfig(configPath) {
6
6
  const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
7
- for (const k of ['workspaceRoot', 'maxSlots'])
8
- if (raw[k] === undefined)
9
- throw new Error(t(`config: 缺少必填字段 ${k}`, `config: ${k} required`));
7
+ if (raw.maxSlots === undefined)
8
+ throw new Error(t('config: 缺少必填字段 maxSlots', 'config: maxSlots required'));
9
+ const configDir = path.dirname(configPath);
10
+ // workspaceRoot 非必选,默认当前目录(= config 所在目录);相对路径相对 config 目录解析,
11
+ // 已是绝对路径时 path.resolve 原样返回(向后兼容,且不再受进程 cwd 影响)
12
+ const workspaceRoot = path.resolve(configDir, raw.workspaceRoot ?? '.');
10
13
  const maxSlots = raw.maxSlots;
11
14
  const services = raw.services ?? {};
12
15
  // 端口段:每个服务 [port, port+maxSlots](port=主 dev/槽0,槽 1..maxSlots 落在段内)
13
16
  for (const [name, sp] of Object.entries(services)) {
14
17
  if (typeof sp.port !== 'number' || sp.port < 1)
15
18
  throw new Error(t(`config: ${name}.port 必须是正整数`, `config: ${name}.port must be a positive number`)); // V2
16
- const repoDir = path.join(raw.workspaceRoot, sp.repo ?? name);
19
+ const repoDir = path.join(workspaceRoot, sp.repo ?? name);
17
20
  if (!fs.existsSync(repoDir))
18
21
  throw new Error(t(`config: ${name}.repo 目录不存在: ${repoDir}(检查 workspaceRoot 与 repo 名,或先 git clone)`, `config: ${name}.repo dir missing: ${repoDir} (check workspaceRoot and the repo name, or clone it first)`)); // V5
19
22
  }
@@ -32,7 +35,7 @@ export function parseConfig(configPath) {
32
35
  for (const ref of refs(v))
33
36
  if (!known.has(ref) && !(sp.vars && ref in sp.vars))
34
37
  throw new Error(t(`config: 未知模板变量 {${ref}}(只能引用内置变量或本服务 vars 里已声明的)`, `config: unknown template var {${ref}} (only built-in vars or this service's declared vars are allowed)`));
35
- return { workspaceRoot: raw.workspaceRoot, maxSlots, services, configDir: path.dirname(configPath) };
38
+ return { workspaceRoot, maxSlots, services, configDir };
36
39
  }
37
40
  export function loadConfig(startDir) {
38
41
  if (process.env.WORKTREE_BAY_CONFIG)
package/dist/engine.js CHANGED
@@ -8,7 +8,7 @@ import { runShellLive, run, spliceArgv, isTTY } from './util/exec.js';
8
8
  import { warn, log } from './util/log.js';
9
9
  import { withProgress } from './util/progress.js';
10
10
  import { color as cc } from './util/color.js';
11
- import { startDetached, recordedFor, pidAlive, setPid, pidOnPort, readLogTail, stopManaged } from './proc.js';
11
+ import { startDetached, recordedFor, pidAlive, setPid, pidOnPort, readLogTail, stopManaged, stopUnrecordedOnPort } from './proc.js';
12
12
  import { t } from './i18n.js';
13
13
  export function mergeEnvText(text, kv) {
14
14
  const lines = text.split('\n');
@@ -82,7 +82,7 @@ export async function ensureStarted(ctx) {
82
82
  log(cc.dim(t(` • ${service} dev server 已在跑(pid ${rec.pid},端口 ${port})`, ` • ${service} dev server already running (pid ${rec.pid}, port ${port})`)));
83
83
  return;
84
84
  }
85
- if (await portInUse(port)) {
85
+ if (pidOnPort(port)) {
86
86
  log(cc.dim(t(` • 端口 ${port} 已在监听,视为 ${service} dev server 在跑,跳过启动`, ` • port ${port} already listening; treating ${service} dev server as up, skip`)));
87
87
  return;
88
88
  }
@@ -115,24 +115,53 @@ async function waitForListen(port, ms) {
115
115
  }
116
116
  }
117
117
  // 「运行体」= docker 容器(infra) + node dev server。up 重入 / start / restart 共用,统一边界。
118
- // 恢复运行体:有 stop 钩子的 infra 服务重跑 setup(docker compose up -d 幂等恢复)+ 起 managed dev server。
118
+ // 恢复运行体:有 stop 钩子的 infra 服务在端口没监听时才重跑 setup(docker compose up -d)+ 起 managed dev server。
119
+ // 「是否在跑」一律按端口实判,与 ls 同源(pidOnPort/netstat,而非 connect 探测——docker 发布端口两者会不一致,
120
+ // 导致 ls 显示在跑、start 却误判没跑去「恢复」)。端口已在监听就视为在跑、跳过,不再无脑重跑 setup。
119
121
  export async function ensureRuntime(ctx) {
120
122
  const { sp, dir, service, vars } = ctx;
121
123
  if (sp.stop && sp.setup) {
122
- const cmd = renderTemplate(sp.setup, vars);
123
- await runShellLive(cmd, { cwd: dir }, t(`恢复 ${service}:${cmd}`, `resume ${service}: ${cmd}`));
124
+ const port = Number(vars.port);
125
+ if (pidOnPort(port))
126
+ log(cc.dim(t(` • ${service} 已在跑(端口 ${port} 在监听),跳过恢复`, ` • ${service} already up (port ${port} listening), skip resume`)));
127
+ else {
128
+ const cmd = renderTemplate(sp.setup, vars);
129
+ await runShellLive(cmd, { cwd: dir }, t(`恢复 ${service}:${cmd}`, `resume ${service}: ${cmd}`));
130
+ }
124
131
  }
125
132
  await ensureStarted(ctx);
126
133
  }
127
134
  // 停止运行体:杀 managed dev server + 跑 stop 钩子(docker compose stop)。不动 worktree。
135
+ // 始终给每个服务输出一行状态。「是否在跑」用 pidOnPort(与 ls 同源),不用 connect 探测。
136
+ // 严格判定:只停「本目录 + 本端口 + 本进程」——dev server 凭账本(dir 已规范化匹配)认本进程,
137
+ // 不去按端口盲杀(端口可能被无关进程占);没有账本记录就如实报告、不动它。
128
138
  export async function stopRuntime(ctx) {
129
139
  const { cfg, sp, dir, service, vars } = ctx;
140
+ const port = Number(vars.port);
141
+ const onPort = pidOnPort(port); // 停之前先记下端口真相(stopManaged 可能把它杀掉)
130
142
  const stopped = stopManaged(cfg.workspaceRoot, dir);
131
143
  if (stopped)
132
- log(` ${cc.green('✓')} ` + t(`已停止 dev server(pid ${stopped.pid})`, `stopped dev server (pid ${stopped.pid})`));
144
+ log(` ${cc.green('✓')} ` + t(`已停止 dev server(pid ${stopped.pid},端口 ${stopped.port})`, `stopped dev server (pid ${stopped.pid}, port ${stopped.port})`));
133
145
  if (sp.stop) {
146
+ // stop 钩子始终跑(docker compose stop 幂等,且能收掉 app 端口没监听、但 mysql/redis 等边车还在的情况)。
134
147
  const cmd = renderTemplate(sp.stop, vars);
135
148
  await runShellLive(cmd, { cwd: dir }, t(`停 ${service}:${cmd}`, `stop ${service}: ${cmd}`));
149
+ if (!onPort)
150
+ log(` ${cc.dim('•')} ` + t(`(端口 ${port} 此前空闲,${service} 实际并未在对外服务)`, `(port ${port} was idle; ${service} wasn't actually serving)`));
151
+ }
152
+ if (!stopped && !sp.stop) {
153
+ if (!onPort) {
154
+ log(` ${cc.dim('•')} ` + t('未在运行', 'not running'));
155
+ return;
156
+ }
157
+ // 无账本记录:校验后才杀(cwd==本目录 或 命令行含本端口),确证不了就不动
158
+ const r = stopUnrecordedOnPort(cfg.workspaceRoot, dir, port);
159
+ if (r.confirmed)
160
+ log(` ${cc.green('✓')} ` + t(`已停止(端口 ${port} 上 pid ${r.pid},经${r.how === 'cwd' ? '工作目录' : '命令行'}确认属本 worktree)`, `stopped (pid ${r.pid} on port ${port}, confirmed as this worktree by ${r.how})`));
161
+ else if (r.reason === 'cwd-mismatch')
162
+ log(` ${cc.yellow('•')} ` + t(`端口 ${port} 被 pid ${r.pid} 占用,但其工作目录非本 worktree(${r.cwd}),未停`, `port ${port} held by pid ${r.pid}, but its cwd isn't this worktree (${r.cwd}); left running`));
163
+ else
164
+ log(` ${cc.yellow('•')} ` + t(`端口 ${port} 被 pid ${r.pid} 占用,无账本记录且无法确认是本服务本进程,未自动停止`, `port ${port} held by pid ${r.pid}, no record and can't confirm it's this service; left running`));
136
165
  }
137
166
  }
138
167
  export function execArgv(ctx, cmd) {
package/dist/proc.js CHANGED
@@ -17,8 +17,12 @@ export function pidAlive(pid) { if (!pid || pid < 1)
17
17
  catch {
18
18
  return false;
19
19
  } }
20
- export function recordedFor(ws, dir) { return readProcs(ws).find((r) => r.dir === dir); }
21
- export function setPid(ws, dir, pid) { const recs = readProcs(ws); const r = recs.find((x) => x.dir === dir); if (r) {
20
+ // 账本 dir 可能存成相对/绝对、斜杠或大小写不一(写入与查时来源不同)。统一相对 ws 解析成绝对、
21
+ // Windows 下再大小写折叠后比较,保证「同一个 worktree 目录」一定能匹配上(严格判定的「本目录」凭据)。
22
+ function normDir(ws, p) { const r = path.resolve(ws, p); return process.platform === 'win32' ? r.toLowerCase() : r; }
23
+ function sameDir(ws, a, b) { return normDir(ws, a) === normDir(ws, b); }
24
+ export function recordedFor(ws, dir) { return readProcs(ws).find((r) => sameDir(ws, r.dir, dir)); }
25
+ export function setPid(ws, dir, pid) { const recs = readProcs(ws); const r = recs.find((x) => sameDir(ws, x.dir, dir)); if (r) {
22
26
  r.pid = pid;
23
27
  writeProcs(ws, recs);
24
28
  } }
@@ -55,6 +59,70 @@ export function pidOnPort(port) {
55
59
  }
56
60
  return undefined;
57
61
  }
62
+ // 取进程工作目录(cwd)。Linux/macOS 原生可取(最强的「本目录」证据);
63
+ // Windows 普通命令拿不到他进程 cwd(存在 PEB、需 ReadProcessMemory),返回 undefined,由调用方降级到命令行核对。
64
+ export function processCwd(pid) {
65
+ if (!pid || pid < 1)
66
+ return undefined;
67
+ try {
68
+ if (process.platform === 'linux')
69
+ return fs.readlinkSync(`/proc/${pid}/cwd`);
70
+ if (process.platform === 'darwin') {
71
+ const r = spawnSync('lsof', ['-a', '-d', 'cwd', '-p', String(pid), '-Fn'], { encoding: 'utf8' });
72
+ if (r.status === 0) {
73
+ const m = /^n(.+)$/m.exec(r.stdout || '');
74
+ if (m)
75
+ return m[1].trim();
76
+ }
77
+ }
78
+ }
79
+ catch { /* 取不到就降级 */ }
80
+ return undefined;
81
+ }
82
+ // 取进程命令行(cwd 取不到时的跨平台兜底,尤其 Windows)。
83
+ export function processCmdline(pid) {
84
+ if (!pid || pid < 1)
85
+ return undefined;
86
+ try {
87
+ if (process.platform === 'win32') {
88
+ const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: 'utf8' });
89
+ if (r.status === 0)
90
+ return (r.stdout || '').trim() || undefined;
91
+ }
92
+ else {
93
+ const r = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' });
94
+ if (r.status === 0)
95
+ return (r.stdout || '').trim() || undefined;
96
+ }
97
+ }
98
+ catch { /* 取不到就放弃确证 */ }
99
+ return undefined;
100
+ }
101
+ // 无账本记录时的严格兜底:端口上的进程,仅当能确证属于本 worktree 才杀(先校验后杀,绝不凭端口盲杀)。
102
+ // 证据:① cwd == 本 worktree 目录(Linux/macOS,最强,证明「本目录」);
103
+ // ② cwd 取不到(Windows)→ 命令行含 `--port <本端口>`(本端口按槽唯一分配,等价确证本服务本进程)。
104
+ // 杀用 killTree(含进程树);都无法确证则不动、返回 confirmed:false 供上层如实报告。
105
+ export function stopUnrecordedOnPort(ws, dir, port) {
106
+ const pid = pidOnPort(port);
107
+ if (!pid)
108
+ return { confirmed: false, reason: 'idle' };
109
+ const cwd = processCwd(pid);
110
+ if (cwd !== undefined) {
111
+ if (sameDir(ws, cwd, dir)) {
112
+ if (pidAlive(pid))
113
+ killTree(pid);
114
+ return { pid, confirmed: true, how: 'cwd', cwd };
115
+ }
116
+ return { pid, confirmed: false, cwd, reason: 'cwd-mismatch' };
117
+ }
118
+ const cmd = processCmdline(pid);
119
+ if (cmd && (cmd.includes(`--port ${port}`) || cmd.includes(`--port=${port}`))) {
120
+ if (pidAlive(pid))
121
+ killTree(pid);
122
+ return { pid, confirmed: true, how: 'cmdline', cmd };
123
+ }
124
+ return { pid, confirmed: false, cmd, reason: 'unverified' };
125
+ }
58
126
  export function startDetached(ws, dir, service, slug, port, cmd) {
59
127
  const logDir = path.join(ws, '.worktree-bay', 'logs');
60
128
  fs.mkdirSync(logDir, { recursive: true });
@@ -69,8 +137,9 @@ export function startDetached(ws, dir, service, slug, port, cmd) {
69
137
  const child = spawn(cmd, { cwd: dir, shell: true, detached, stdio: ['ignore', fd, fd], windowsHide: true });
70
138
  const pid = child.pid ?? -1;
71
139
  child.unref();
72
- const rec = { dir, service, port, pid, cmd, log, startedAt: Date.now() };
73
- writeProcs(ws, [...readProcs(ws).filter((r) => r.dir !== dir), rec]);
140
+ // 一律存绝对 dir,避免相对/绝对混存导致后续按目录匹配漂移
141
+ const rec = { dir: path.resolve(ws, dir), service, port, pid, cmd, log, startedAt: Date.now() };
142
+ writeProcs(ws, [...readProcs(ws).filter((r) => !sameDir(ws, r.dir, dir)), rec]);
74
143
  return rec;
75
144
  }
76
145
  function killTree(pid) {
@@ -92,7 +161,7 @@ function killTree(pid) {
92
161
  // 同时按「记录 pid」和「当前端口占用 pid」双杀——shell/pnpm 中间层会让记录 pid 漂移,按端口兜底最稳。
93
162
  export function stopManaged(ws, dir) {
94
163
  const recs = readProcs(ws);
95
- const rec = recs.find((r) => r.dir === dir);
164
+ const rec = recs.find((r) => sameDir(ws, r.dir, dir));
96
165
  if (!rec)
97
166
  return undefined;
98
167
  const targets = new Set();
@@ -104,6 +173,6 @@ export function stopManaged(ws, dir) {
104
173
  for (const pid of targets)
105
174
  if (pidAlive(pid))
106
175
  killTree(pid);
107
- writeProcs(ws, recs.filter((r) => r.dir !== dir));
176
+ writeProcs(ws, recs.filter((r) => !sameDir(ws, r.dir, dir)));
108
177
  return rec;
109
178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-bay",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "description": "Per-feature git worktree + port slots for parallel multi-service development: auto deps, env wiring, frontend-to-backend, merge-aware reclaim, plus an MCP server for AI agents.",
5
5
  "keywords": [
6
6
  "git",
package/skill.md CHANGED
@@ -66,7 +66,7 @@ worktree-bay gc # 回收已合并的
66
66
 
67
67
  | 字段 | 类型 | 说明 |
68
68
  |---|---|---|
69
- | `workspaceRoot` | string | 工作区根绝对路径,各服务仓在其下 |
69
+ | `workspaceRoot` | string | **非必选**,工作区根,各服务仓在其下;**默认 = config 文件所在目录**(即省略时等同 `"."`)。可填相对路径(相对 config 目录解析,不受进程 cwd 影响)或绝对路径 |
70
70
  | `maxSlots` | number | 最大并行功能数(每个服务预留 `maxSlots` 个端口),如 `9` |
71
71
  | `services` | object | 服务名 → 服务定义(见下) |
72
72
 
@@ -85,7 +85,7 @@ worktree-bay gc # 回收已合并的
85
85
  | `upstream` | | object | 声明依赖的上游服务:`{ "service": "api", "fallback": "http://localhost:6001" }`,产出 `{upstreamBase}` 变量 |
86
86
  | `setup` | | string | 挂入后执行的 shell 命令(创建/装好运行体,如 `docker compose up -d`、`pnpm install`)。`up` 时跑 |
87
87
  | `teardown` | | string | 拆除时执行的 shell 命令(销毁运行体,如 `docker compose down -v`)。`down`/`rm`/`gc` 时跑 |
88
- | `start` | | string | 长进程 dev server(如 `pnpm dev`)。`up` 时**自动后台启动**(detach + 日志落 `.worktree-bay/logs/`),按端口追踪 pid;由 `start`/`stop`/`restart` 控制,`ls` `▸run` |
88
+ | `start` | | string | 长进程 dev server(如 `pnpm dev`)。`up` 时**自动后台启动**(detach + 日志落 `.worktree-bay/logs/`),按端口追踪 pid;由 `start`/`stop`/`restart` 控制,`ls` 行首 `●` 标在跑(绿)/未跑(灰) |
89
89
  | `stop` | | string | 停止该服务 infra 运行体的 shell(如 `docker compose stop`)。供 `stop`/`restart` 用——让 docker 容器能「停而不毁」;`start` 时对配了它的服务重跑 `setup` 幂等恢复 |
90
90
  | `exec` | | string[] | 透传命令模板(argv 数组),`{cmd...}` 是 argv splice 占位,防 shell 注入。如 `["docker","exec","-i","{project}-app-1","{cmd...}"]` |
91
91
  | `run` | | object | 命名命令:`{ "test": ["composer","run","test"] }`,供 `worktree-bay run <feature> <service> test` 调用 |
@@ -162,7 +162,9 @@ worktree-bay gc # 回收已合并的
162
162
  ## 工作原理要点
163
163
 
164
164
  - **占用真相 = 文件系统**:扫各服务 `.worktrees/s<N>-*`;`.worktree-bay-slots.json` 只是「功能名→槽号」标签账本(预约)。
165
- - **dev server 托管**:`start` 进程后台 detach 启动、日志落 `.worktree-bay/logs/`、按端口追踪真实 pid;`ls` `▸run`;`stop`/`down` 按端口可靠停。
165
+ - **dev server 托管**:`start` 进程后台 detach 启动、日志落 `.worktree-bay/logs/`、按端口追踪真实 pid;`ls` 行首 `●` 标在跑(绿)/未跑(灰);`stop`/`down` 按端口可靠停。
166
+ - **运行状态判断 = 端口**:`ls`/`start`/`stop` 判「在不在跑」全用 `pidOnPort`(netstat/lsof,与 ls 同源),不用 connect 探测(docker 发布端口两者会不一致)、也不只看 pid 账本(dir 形态会漂移、docker 无账本记录)。`start` 端口已在监听就跳过、不再误报「恢复」。
167
+ - **stop 严格停「本目录+本端口+本进程」**:① 优先用启动账本(dir 已规范化匹配:相对/绝对/大小写都认);② 账本缺失时**校验后才杀**——Linux/macOS 比对进程 `cwd` 是否为本 worktree,Windows 取不到 cwd 则核对命令行含 `--port <本端口>`(端口按槽唯一,等价确证);都确证不了就**不动它**并如实报告(绝不凭端口盲杀)。每个服务都有状态行:已停 / 端口空闲 / 无法确认未停。
166
168
  - **并发安全**:`claim/add/up/rm/down/gc` 全程持工作区原子锁。
167
169
  - **前端自接后端**:前端有 `upstream` 且同槽该上游服务的 worktree 已建,则 `{upstreamBase}` = 本槽上游端口;否则用 `fallback`。所以**联调时先 `up`/`add` 后端,再起前端**。
168
170
  - **合并感知回收**:`gc` 先 `git fetch`,用 `merge-base --is-ancestor` 判断是否并入主分支;**只在「已合并 + 工作区干净 + 无未推」时才自动删**,判不准一律保守不删、只标记。
@@ -171,7 +173,7 @@ worktree-bay gc # 回收已合并的
171
173
 
172
174
  ## 给 AI(MCP)
173
175
 
174
- `worktree-bay mcp` 暴露工具:`worktree_bay_doctor / ls / up / claim / add / path / run / start / stop / restart / down / gc / init / skill`。`doctor` 列出全部服务(AI 借此得知服务名);`ls` 以 JSON 返回(含各 worktree 绝对路径、`▸run`);`path` 给某功能某服务的 worktree 目录;`start/stop/restart` 控制运行体(docker+node);`down` 省略 services 拆整功能、给 services 只拆这些。要写或修改 `worktree-bay.config.json`、或拿不准命令/配置细节时,调用 `worktree_bay_skill` 取本指南全文。
176
+ `worktree-bay mcp` 暴露工具:`worktree_bay_doctor / ls / up / claim / add / path / run / start / stop / restart / down / gc / init / skill`。`doctor` 列出全部服务(AI 借此得知服务名);`ls` 以 JSON 返回 `[{slot, feature(可 null), services:[{service, port, dir, running}]}]`(含各 worktree 绝对路径、布尔 `running`);`path` 给某功能某服务的 worktree 目录;`start/stop/restart` 控制运行体(docker+node);`down` 省略 services 拆整功能、给 services 只拆这些。要写或修改 `worktree-bay.config.json`、或拿不准命令/配置细节时,调用 `worktree_bay_skill` 取本指南全文。
175
177
 
176
178
  ## 常见坑
177
179