@lesscap/baton-cli 0.7.2 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +18 -8
  2. package/dist/baton.mjs +263 -118
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -24,8 +24,12 @@ in (the worker spawns it to run each turn). Override the path with
24
24
  cd /path/to/your-repo
25
25
 
26
26
  # 2. register this machine as a worker for a project
27
- baton worker register --url https://baton.fmap.dev/api --project <projectId> --name my-machine
28
- # → writes ./.baton.json { server, project, worker token }
27
+ baton worker register \
28
+ --url https://baton.fmap.dev/api \
29
+ --project <projectId> \
30
+ --name my-machine \
31
+ --baseBranch main
32
+ # → generates ./.baton.json with server, project, base branch, and worker token
29
33
 
30
34
  # 3. run the worker daemon (foreground; Ctrl-C to stop)
31
35
  baton worker run
@@ -35,12 +39,18 @@ The worker authenticates with its own bearer token — **no login needed**. Once
35
39
  running, it shows up (alive) under the project's workers in the web back-office;
36
40
  new sessions / inbound DingTalk messages are then executed by it.
37
41
 
38
- `.baton.json` is per-directory local state (gitignored by convention). You can
39
- also create it by hand before registering:
40
-
41
- ```json
42
- { "server": "https://baton.fmap.dev/api", "project": 2 }
43
- ```
42
+ `.baton.json` is per-directory local state (gitignored by convention) and is
43
+ generated by `baton worker register`; `baton init` is optional. `--baseBranch`
44
+ wins over `BATON_BASE_BRANCH`, then an existing saved value. If none is set,
45
+ registration records the repo's current branch, falling back to `main`.
46
+
47
+ Before creating each new session worktree, the worker fetches that branch and
48
+ forks from the updated `origin/<branch>` ref without moving the source checkout.
49
+ Repositories without an `origin` use their local branch. Environments that keep
50
+ Git credentials in a separate broker can set `BATON_GIT_SYNC_BIN` to a trusted
51
+ executable; Baton invokes it as `<executable> <repo> <branch>`, then verifies the
52
+ updated remote-tracking ref before creating the worktree. The helper must print
53
+ the synced commit hash to stdout.
44
54
 
45
55
  ## Other commands
46
56
 
package/dist/baton.mjs CHANGED
@@ -4684,11 +4684,11 @@ var channelClient = (baseUrl) => {
4684
4684
  if (!res.ok) throw new Error(`GET channel \u2192 ${res.status}: ${await res.text()}`);
4685
4685
  return await res.json();
4686
4686
  },
4687
- update: async (channelId, patch2) => {
4687
+ update: async (channelId, patch) => {
4688
4688
  const res = await fetch(u(`/channels/${channelId}`), {
4689
4689
  method: "PATCH",
4690
4690
  headers: JSON_CT,
4691
- body: JSON.stringify(patch2)
4691
+ body: JSON.stringify(patch)
4692
4692
  });
4693
4693
  if (!res.ok) throw new Error(`PATCH channel \u2192 ${res.status}: ${await res.text()}`);
4694
4694
  return await res.json();
@@ -4797,10 +4797,6 @@ var saveProjectConfig = (path, config) => {
4797
4797
  writeFileSync(path, `${JSON.stringify(config, null, 2)}
4798
4798
  `, "utf8");
4799
4799
  };
4800
- var patch = (path, fn) => {
4801
- saveProjectConfig(path, fn(loadProjectConfig(path)));
4802
- };
4803
- var setWorker = (path, w) => patch(path, (cfg) => ({ ...cfg, worker: w }));
4804
4800
  var requireBase = (cfg) => {
4805
4801
  if (!cfg.server || !cfg.project || !cfg.worker)
4806
4802
  throw new Error("project-config missing server/project/worker \u2014 run `baton worker register`");
@@ -4811,6 +4807,7 @@ var viewWorker = (cfg) => {
4811
4807
  return {
4812
4808
  server,
4813
4809
  projectId: project2,
4810
+ baseBranch: cfg.baseBranch,
4814
4811
  workerId: worker2.id,
4815
4812
  agentKind: worker2.agentKind ?? "claude-code",
4816
4813
  name: worker2.name,
@@ -4821,6 +4818,7 @@ var viewWorker = (cfg) => {
4821
4818
  var worktreeConfig = (cfg) => ({
4822
4819
  server: cfg.server,
4823
4820
  project: cfg.projectId,
4821
+ baseBranch: cfg.baseBranch,
4824
4822
  worker: {
4825
4823
  id: cfg.workerId,
4826
4824
  name: cfg.name,
@@ -4944,7 +4942,7 @@ var projectClient = (baseUrl) => {
4944
4942
  create: (input) => request(u("/projects"), { method: "POST", body: input }),
4945
4943
  listByWorkspace: (workspaceId) => request(u(`/workspaces/${workspaceId}/projects`), { method: "GET" }),
4946
4944
  get: (id) => request(u(`/projects/${id}`), { method: "GET" }),
4947
- update: (id, patch2) => request(u(`/projects/${id}`), { method: "PATCH", body: patch2 }),
4945
+ update: (id, patch) => request(u(`/projects/${id}`), { method: "PATCH", body: patch }),
4948
4946
  remove: (id) => request(u(`/projects/${id}`), { method: "DELETE" })
4949
4947
  };
4950
4948
  };
@@ -4967,7 +4965,7 @@ var requirementClient = (baseUrl) => {
4967
4965
  listByProject: (projectId) => request(u(`/projects/${projectId}/requirements`), { method: "GET" }),
4968
4966
  get: (id) => request(u(`/requirements/${id}`), { method: "GET" }),
4969
4967
  getByCode: (projectId, code) => fetchItemByCode(baseUrl, projectId, code, "requirement"),
4970
- update: (id, patch2) => request(u(`/requirements/${id}`), { method: "PATCH", body: patch2 }),
4968
+ update: (id, patch) => request(u(`/requirements/${id}`), { method: "PATCH", body: patch }),
4971
4969
  setStatus: (id, status) => request(u(`/requirements/${id}`), { method: "PATCH", body: { status } }),
4972
4970
  remove: (id) => request(u(`/requirements/${id}`), { method: "DELETE" })
4973
4971
  };
@@ -5061,7 +5059,7 @@ var taskClient = (baseUrl) => {
5061
5059
  listByRequirement: (requirementId) => request(u(`/requirements/${requirementId}/tasks`), { method: "GET" }),
5062
5060
  get: (id) => request(u(`/tasks/${id}`), { method: "GET" }),
5063
5061
  getByCode: (projectId, code) => fetchItemByCode(baseUrl, projectId, code, "task"),
5064
- update: (id, patch2) => request(u(`/tasks/${id}`), { method: "PATCH", body: patch2 }),
5062
+ update: (id, patch) => request(u(`/tasks/${id}`), { method: "PATCH", body: patch }),
5065
5063
  setStatus: (id, status) => request(u(`/tasks/${id}`), { method: "PATCH", body: { status } }),
5066
5064
  remove: (id) => request(u(`/tasks/${id}`), { method: "DELETE" }),
5067
5065
  listComments: (id) => request(u(`/tasks/${id}/comments`), { method: "GET" }),
@@ -5097,7 +5095,7 @@ var workspaceClient = (baseUrl) => {
5097
5095
  create: (input) => request(u("/workspaces"), { method: "POST", body: input }),
5098
5096
  list: () => request(u("/workspaces"), { method: "GET" }),
5099
5097
  get: (id) => request(u(`/workspaces/${id}`), { method: "GET" }),
5100
- update: (id, patch2) => request(u(`/workspaces/${id}`), { method: "PATCH", body: patch2 }),
5098
+ update: (id, patch) => request(u(`/workspaces/${id}`), { method: "PATCH", body: patch }),
5101
5099
  remove: (id) => request(u(`/workspaces/${id}`), { method: "DELETE" })
5102
5100
  };
5103
5101
  };
@@ -5855,11 +5853,11 @@ var loopSetCommand = defineCommand({
5855
5853
  },
5856
5854
  run: async ({ args }) => {
5857
5855
  const c = clientFor(args);
5858
- const patch2 = {};
5859
- if (args.message !== void 0) patch2.message = args.message;
5860
- if (args.every !== void 0 && args.every !== "") patch2.intervalSec = parseDuration(args.every);
5861
- if (args.name !== void 0) patch2.name = args.name;
5862
- const loop2 = await c.loops.update(Number(args.loop), patch2);
5856
+ const patch = {};
5857
+ if (args.message !== void 0) patch.message = args.message;
5858
+ if (args.every !== void 0 && args.every !== "") patch.intervalSec = parseDuration(args.every);
5859
+ if (args.name !== void 0) patch.name = args.name;
5860
+ const loop2 = await c.loops.update(Number(args.loop), patch);
5863
5861
  console.log(renderOne(loop2, fmtLoop, Boolean(args.json)));
5864
5862
  }
5865
5863
  });
@@ -5882,7 +5880,7 @@ var loop = defineCommand({
5882
5880
  var createProject = (c, input, json) => c.projects.create(input).then((p) => renderOne(p, fmtProject, json));
5883
5881
  var listProjects = (c, workspaceId, json) => c.projects.listByWorkspace(workspaceId).then((ps) => renderList(ps, fmtProject, json));
5884
5882
  var getProject = (c, id, json) => c.projects.get(id).then((p) => renderOne(p, fmtProject, json));
5885
- var updateProject = (c, id, patch2, json) => c.projects.update(id, patch2).then((p) => renderOne(p, fmtProject, json));
5883
+ var updateProject = (c, id, patch, json) => c.projects.update(id, patch).then((p) => renderOne(p, fmtProject, json));
5886
5884
  var removeProject = (c, id, json) => c.projects.remove(id).then(() => removed("project", id, json));
5887
5885
  var project = defineCommand({
5888
5886
  meta: { name: "project", description: "manage projects" },
@@ -5932,13 +5930,13 @@ var project = defineCommand({
5932
5930
  ...common
5933
5931
  },
5934
5932
  run: async ({ args }) => {
5935
- const patch2 = {
5933
+ const patch = {
5936
5934
  ...args.name !== void 0 ? { name: args.name } : {},
5937
5935
  ...args.desc !== void 0 ? { description: args.desc } : {}
5938
5936
  };
5939
- if (Object.keys(patch2).length === 0) throw new Error("pass --name and/or --desc");
5937
+ if (Object.keys(patch).length === 0) throw new Error("pass --name and/or --desc");
5940
5938
  console.log(
5941
- await updateProject(clientFor(args), Number(args.id), patch2, Boolean(args.json))
5939
+ await updateProject(clientFor(args), Number(args.id), patch, Boolean(args.json))
5942
5940
  );
5943
5941
  }
5944
5942
  }),
@@ -6131,7 +6129,7 @@ var relay = defineCommand({
6131
6129
 
6132
6130
  // src/commands/requirement.ts
6133
6131
  var createRequirement = (c, input, json) => c.requirements.create(input).then((r) => renderOne(r, fmtRequirement, json));
6134
- var updateRequirement = (c, id, patch2, json) => c.requirements.update(id, patch2).then((r) => renderOne(r, fmtRequirement, json));
6132
+ var updateRequirement = (c, id, patch, json) => c.requirements.update(id, patch).then((r) => renderOne(r, fmtRequirement, json));
6135
6133
  var listRequirements = (c, projectId, json) => c.requirements.listByProject(projectId).then((rs) => renderList(rs, fmtRequirement, json));
6136
6134
  var getRequirement = (c, id, json) => c.requirements.get(id).then((r) => renderOne(r, fmtRequirement, json));
6137
6135
  var setRequirementStatus = (c, id, status, json) => c.requirements.setStatus(id, status).then((r) => renderOne(r, fmtRequirement, json));
@@ -6174,14 +6172,14 @@ var requirement = defineCommand({
6174
6172
  ...common
6175
6173
  },
6176
6174
  run: async ({ args }) => {
6177
- const patch2 = {
6175
+ const patch = {
6178
6176
  ...args.title !== void 0 ? { title: args.title } : {},
6179
6177
  ...args.body !== void 0 ? { body: args.body } : {}
6180
6178
  };
6181
- if (Object.keys(patch2).length === 0) throw new Error("pass --title and/or --body");
6179
+ if (Object.keys(patch).length === 0) throw new Error("pass --title and/or --body");
6182
6180
  const c = clientFor(args);
6183
6181
  const id = await resolveByCode(c, resolveProjectId(args), args.code);
6184
- console.log(await updateRequirement(c, id, patch2, Boolean(args.json)));
6182
+ console.log(await updateRequirement(c, id, patch, Boolean(args.json)));
6185
6183
  }
6186
6184
  }),
6187
6185
  ls: defineCommand({
@@ -7423,7 +7421,7 @@ var session = defineCommand({
7423
7421
 
7424
7422
  // src/commands/task.ts
7425
7423
  var createTask = (c, input, json) => c.tasks.create(input).then((t) => renderOne(t, fmtTask, json));
7426
- var updateTask = (c, id, patch2, json) => c.tasks.update(id, patch2).then((t) => renderOne(t, fmtTask, json));
7424
+ var updateTask = (c, id, patch, json) => c.tasks.update(id, patch).then((t) => renderOne(t, fmtTask, json));
7427
7425
  var listTasks = (c, requirementId, json) => c.tasks.listByRequirement(requirementId).then((ts) => renderList(ts, fmtTask, json));
7428
7426
  var getTask = (c, id, json) => c.tasks.get(id).then((t) => renderOne(t, fmtTask, json));
7429
7427
  var setTaskStatus = (c, id, status, json) => c.tasks.setStatus(id, status).then((t) => renderOne(t, fmtTask, json));
@@ -7477,14 +7475,14 @@ var task = defineCommand({
7477
7475
  ...common
7478
7476
  },
7479
7477
  run: async ({ args }) => {
7480
- const patch2 = {
7478
+ const patch = {
7481
7479
  ...args.title !== void 0 ? { title: args.title } : {},
7482
7480
  ...args.body !== void 0 ? { body: args.body } : {}
7483
7481
  };
7484
- if (Object.keys(patch2).length === 0) throw new Error("pass --title and/or --body");
7482
+ if (Object.keys(patch).length === 0) throw new Error("pass --title and/or --body");
7485
7483
  const c = clientFor(args);
7486
7484
  const id = await resolveTaskByCode(c, resolveProjectId(args), args.code);
7487
- console.log(await updateTask(c, id, patch2, Boolean(args.json)));
7485
+ console.log(await updateTask(c, id, patch, Boolean(args.json)));
7488
7486
  }
7489
7487
  }),
7490
7488
  ls: defineCommand({
@@ -7583,6 +7581,155 @@ var task = defineCommand({
7583
7581
  // src/commands/worker/index.ts
7584
7582
  import { hostname as osHostname } from "node:os";
7585
7583
 
7584
+ // src/session/worktree.ts
7585
+ import { execFile, execFileSync, spawnSync as spawnSync2 } from "node:child_process";
7586
+ import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync5 } from "node:fs";
7587
+ import { dirname, isAbsolute as isAbsolute2, resolve } from "node:path";
7588
+ import { promisify } from "node:util";
7589
+ var git = (repo, args) => spawnSync2("git", ["-C", repo, ...args], {
7590
+ stdio: "pipe",
7591
+ encoding: "utf8"
7592
+ });
7593
+ var commandError = (result) => {
7594
+ const raw = result.stderr || result.stdout || result.error?.message || `exit ${result.status}`;
7595
+ return raw.trim().replace(/(https?:\/\/)[^/@\s]+@/g, "$1***@");
7596
+ };
7597
+ var execFileAsync = promisify(execFile);
7598
+ var DEFAULT_SYNC_TIMEOUT_MS = 6e4;
7599
+ var asyncCommandError = (error, timeoutMs) => {
7600
+ const failure = error;
7601
+ if (failure.killed || failure.code === "ETIMEDOUT") return `timed out after ${timeoutMs}ms`;
7602
+ const raw = failure.stderr || failure.stdout || failure.message || String(error);
7603
+ return raw.trim().replace(/(https?:\/\/)[^/@\s]+@/g, "$1***@");
7604
+ };
7605
+ var validateBaseBranch = (branch) => {
7606
+ const normalized = branch.trim();
7607
+ const valid = spawnSync2("git", ["check-ref-format", "--branch", normalized], {
7608
+ stdio: "pipe",
7609
+ encoding: "utf8"
7610
+ });
7611
+ if (valid.status !== 0)
7612
+ throw new Error(`invalid base branch "${normalized}": ${commandError(valid)}`);
7613
+ return normalized;
7614
+ };
7615
+ var createWorktree = (input) => {
7616
+ if (existsSync4(input.worktreePath))
7617
+ throw new Error(`worktree path already exists: ${input.worktreePath}`);
7618
+ const head = spawnSync2("git", ["-C", input.repo, "rev-parse", "--verify", "-q", "HEAD"], {
7619
+ stdio: "pipe",
7620
+ encoding: "utf8"
7621
+ });
7622
+ if (head.status !== 0)
7623
+ throw new Error(
7624
+ `repo has no commits (unborn HEAD) \u2014 create an initial commit before starting sessions`
7625
+ );
7626
+ mkdirSync3(dirname(input.worktreePath), { recursive: true });
7627
+ const branch = `baton/${input.sessionCode}`;
7628
+ const r = spawnSync2(
7629
+ "git",
7630
+ ["-C", input.repo, "worktree", "add", "-b", branch, input.worktreePath, input.base],
7631
+ { stdio: "pipe", encoding: "utf8" }
7632
+ );
7633
+ if (r.status !== 0) {
7634
+ throw new Error(`git worktree add failed: ${r.stderr || r.stdout || `exit ${r.status}`}`);
7635
+ }
7636
+ };
7637
+ var repoHeadBranch = (repo) => {
7638
+ const r = git(repo, ["symbolic-ref", "--short", "HEAD"]);
7639
+ const branch = r.status === 0 ? r.stdout.trim() : "";
7640
+ return branch || "main";
7641
+ };
7642
+ var syncBaseBranch = async (repo, branch, options = {}) => {
7643
+ const checkedBranch = validateBaseBranch(branch);
7644
+ const env = options.env ?? process.env;
7645
+ const timeoutMs = options.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS;
7646
+ const origin = git(repo, ["remote", "get-url", "origin"]);
7647
+ if (origin.status !== 0) {
7648
+ const localRef = `refs/heads/${checkedBranch}`;
7649
+ const verified2 = git(repo, ["rev-parse", "--verify", "-q", localRef]);
7650
+ if (verified2.status !== 0) throw new Error(`local base branch does not exist: ${checkedBranch}`);
7651
+ return localRef;
7652
+ }
7653
+ const helper = env.BATON_GIT_SYNC_BIN?.trim();
7654
+ const command = helper ?? "git";
7655
+ const args = helper ? [repo, checkedBranch] : [
7656
+ "-C",
7657
+ repo,
7658
+ "fetch",
7659
+ "--no-tags",
7660
+ "origin",
7661
+ `+refs/heads/${checkedBranch}:refs/remotes/origin/${checkedBranch}`
7662
+ ];
7663
+ let synced;
7664
+ try {
7665
+ synced = await execFileAsync(command, args, {
7666
+ encoding: "utf8",
7667
+ env: { ...env, GIT_TERMINAL_PROMPT: "0" },
7668
+ timeout: timeoutMs
7669
+ });
7670
+ } catch (error) {
7671
+ const via = helper ? ` via ${helper}` : "";
7672
+ throw new Error(
7673
+ `git sync failed for origin/${checkedBranch}${via}: ${asyncCommandError(error, timeoutMs)}`
7674
+ );
7675
+ }
7676
+ const remoteRef = `refs/remotes/origin/${checkedBranch}`;
7677
+ const verified = git(repo, ["rev-parse", "--verify", "-q", remoteRef]);
7678
+ if (verified.status !== 0) throw new Error(`git sync succeeded but ${remoteRef} does not exist`);
7679
+ if (helper) {
7680
+ const reportedCommit = synced.stdout.trim();
7681
+ const actualCommit = verified.stdout.trim();
7682
+ if (!/^[0-9a-f]{40,64}$/i.test(reportedCommit))
7683
+ throw new Error("git sync helper did not report the synced commit on stdout");
7684
+ if (reportedCommit !== actualCommit)
7685
+ throw new Error(`git sync helper reported a commit that does not match ${remoteRef}`);
7686
+ }
7687
+ return remoteRef;
7688
+ };
7689
+ var ensureExcluded = (repo, pattern) => {
7690
+ try {
7691
+ const r = spawnSync2("git", ["-C", repo, "rev-parse", "--git-path", "info/exclude"], {
7692
+ stdio: "pipe",
7693
+ encoding: "utf8"
7694
+ });
7695
+ if (r.status !== 0) return;
7696
+ const rel = r.stdout.trim();
7697
+ const path = isAbsolute2(rel) ? rel : resolve(repo, rel);
7698
+ const current = existsSync4(path) ? readFileSync5(path, "utf8") : "";
7699
+ if (current.split("\n").includes(pattern)) return;
7700
+ mkdirSync3(dirname(path), { recursive: true });
7701
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
7702
+ appendFileSync(path, `${sep}${pattern}
7703
+ `, "utf8");
7704
+ } catch {
7705
+ }
7706
+ };
7707
+ var removeWorktree = (repo, worktreePath) => {
7708
+ if (!existsSync4(worktreePath)) return;
7709
+ try {
7710
+ execFileSync("git", ["-C", repo, "worktree", "remove", "--force", worktreePath], {
7711
+ stdio: "pipe"
7712
+ });
7713
+ } catch {
7714
+ }
7715
+ };
7716
+ var restoreWorktree = async (repo, worktreePath, sessionCode, baseBranch = repoHeadBranch(repo)) => {
7717
+ spawnSync2("git", ["-C", repo, "worktree", "prune"], { stdio: "pipe" });
7718
+ mkdirSync3(dirname(worktreePath), { recursive: true });
7719
+ const branch = `baton/${sessionCode}`;
7720
+ const reuse = spawnSync2("git", ["-C", repo, "worktree", "add", worktreePath, branch], {
7721
+ stdio: "pipe",
7722
+ encoding: "utf8"
7723
+ });
7724
+ if (reuse.status === 0) return;
7725
+ createWorktree({
7726
+ repo,
7727
+ worktreePath,
7728
+ sessionCode,
7729
+ base: await syncBaseBranch(repo, baseBranch)
7730
+ });
7731
+ };
7732
+
7586
7733
  // src/worker/watchdog.ts
7587
7734
  var stale = (okAt, now, thresholdMs) => now - okAt > thresholdMs;
7588
7735
  var streamWedged = (isOpen, okAt, now, thresholdMs) => !isOpen && stale(okAt, now, thresholdMs);
@@ -7726,79 +7873,6 @@ var generateTitle = async (input) => {
7726
7873
  return !title || isDeclined(title) ? { kind: "declined" } : { kind: "titled", title };
7727
7874
  };
7728
7875
 
7729
- // src/session/worktree.ts
7730
- import { execFileSync, spawnSync as spawnSync2 } from "node:child_process";
7731
- import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync5 } from "node:fs";
7732
- import { dirname, isAbsolute as isAbsolute2, resolve } from "node:path";
7733
- var createWorktree = (input) => {
7734
- if (existsSync4(input.worktreePath))
7735
- throw new Error(`worktree path already exists: ${input.worktreePath}`);
7736
- const head = spawnSync2("git", ["-C", input.repo, "rev-parse", "--verify", "-q", "HEAD"], {
7737
- stdio: "pipe",
7738
- encoding: "utf8"
7739
- });
7740
- if (head.status !== 0)
7741
- throw new Error(
7742
- `repo has no commits (unborn HEAD) \u2014 create an initial commit before starting sessions`
7743
- );
7744
- mkdirSync3(dirname(input.worktreePath), { recursive: true });
7745
- const branch = `baton/${input.sessionCode}`;
7746
- const r = spawnSync2(
7747
- "git",
7748
- ["-C", input.repo, "worktree", "add", "-b", branch, input.worktreePath, input.base],
7749
- { stdio: "pipe", encoding: "utf8" }
7750
- );
7751
- if (r.status !== 0) {
7752
- throw new Error(`git worktree add failed: ${r.stderr || r.stdout || `exit ${r.status}`}`);
7753
- }
7754
- };
7755
- var repoHeadBranch = (repo) => {
7756
- const r = spawnSync2("git", ["-C", repo, "symbolic-ref", "--short", "HEAD"], {
7757
- stdio: "pipe",
7758
- encoding: "utf8"
7759
- });
7760
- const branch = r.status === 0 ? r.stdout.trim() : "";
7761
- return branch || "main";
7762
- };
7763
- var ensureExcluded = (repo, pattern) => {
7764
- try {
7765
- const r = spawnSync2("git", ["-C", repo, "rev-parse", "--git-path", "info/exclude"], {
7766
- stdio: "pipe",
7767
- encoding: "utf8"
7768
- });
7769
- if (r.status !== 0) return;
7770
- const rel = r.stdout.trim();
7771
- const path = isAbsolute2(rel) ? rel : resolve(repo, rel);
7772
- const current = existsSync4(path) ? readFileSync5(path, "utf8") : "";
7773
- if (current.split("\n").includes(pattern)) return;
7774
- mkdirSync3(dirname(path), { recursive: true });
7775
- const sep = current === "" || current.endsWith("\n") ? "" : "\n";
7776
- appendFileSync(path, `${sep}${pattern}
7777
- `, "utf8");
7778
- } catch {
7779
- }
7780
- };
7781
- var removeWorktree = (repo, worktreePath) => {
7782
- if (!existsSync4(worktreePath)) return;
7783
- try {
7784
- execFileSync("git", ["-C", repo, "worktree", "remove", "--force", worktreePath], {
7785
- stdio: "pipe"
7786
- });
7787
- } catch {
7788
- }
7789
- };
7790
- var restoreWorktree = (repo, worktreePath, sessionCode) => {
7791
- spawnSync2("git", ["-C", repo, "worktree", "prune"], { stdio: "pipe" });
7792
- mkdirSync3(dirname(worktreePath), { recursive: true });
7793
- const branch = `baton/${sessionCode}`;
7794
- const reuse = spawnSync2("git", ["-C", repo, "worktree", "add", worktreePath, branch], {
7795
- stdio: "pipe",
7796
- encoding: "utf8"
7797
- });
7798
- if (reuse.status === 0) return;
7799
- createWorktree({ repo, worktreePath, sessionCode, base: repoHeadBranch(repo) });
7800
- };
7801
-
7802
7876
  // src/worker/proc.ts
7803
7877
  var killProcessGroup = (proc) => {
7804
7878
  if (proc.pid === void 0) return;
@@ -7817,7 +7891,26 @@ var binPath = () => {
7817
7891
  var createSessionSupervisor = (deps) => {
7818
7892
  const { client, cfg, repo, log, hasTerminal, closeTerminal } = deps;
7819
7893
  const worktreeDir = defaultWorktreeDir();
7894
+ const baseBranch = cfg.baseBranch ?? repoHeadBranch(repo);
7895
+ const syncBase = deps.syncBase ?? syncBaseBranch;
7896
+ let syncInFlight = null;
7897
+ const syncedBase = () => {
7898
+ if (syncInFlight) return syncInFlight;
7899
+ const pending = syncBase(repo, baseBranch);
7900
+ syncInFlight = pending;
7901
+ const clear = () => {
7902
+ if (syncInFlight === pending) syncInFlight = null;
7903
+ };
7904
+ void pending.then(clear, clear);
7905
+ return pending;
7906
+ };
7820
7907
  const children = /* @__PURE__ */ new Map();
7908
+ const starts = /* @__PURE__ */ new Map();
7909
+ const startEpochs = /* @__PURE__ */ new Map();
7910
+ const currentStartEpoch = (sessionId) => startEpochs.get(sessionId) ?? 0;
7911
+ const cancelStart = (sessionId) => {
7912
+ startEpochs.set(sessionId, currentStartEpoch(sessionId) + 1);
7913
+ };
7821
7914
  const spawnChild = (sessionId, worktreePath) => {
7822
7915
  if (children.has(sessionId)) return;
7823
7916
  const child = spawn(process.execPath, [binPath(), "session", "run", String(sessionId)], {
@@ -7834,26 +7927,33 @@ var createSessionSupervisor = (deps) => {
7834
7927
  });
7835
7928
  });
7836
7929
  };
7837
- const start = async (sessionId, name) => {
7930
+ const startOne = async (sessionId, name, epoch) => {
7931
+ const wasCanceled = () => currentStartEpoch(sessionId) !== epoch;
7932
+ if (wasCanceled()) return;
7838
7933
  if (hasTerminal(sessionId))
7839
7934
  return log(`session #${sessionId} has an open terminal \u2014 skipping headless start`);
7840
7935
  if (children.has(sessionId)) return log(`session #${sessionId} already running`);
7841
7936
  const session2 = await client.sessions.get(sessionId);
7937
+ if (wasCanceled()) return log(`session #${sessionId} start canceled before materializing`);
7842
7938
  let worktreePath = session2.worktreePath;
7843
7939
  if (!session2.agentSessionId || !worktreePath) {
7844
7940
  const sessionCode = randomUUID();
7845
7941
  const agentSessionId = session2.agentKind === "codex" ? `pending:${sessionCode}` : sessionCode;
7846
7942
  worktreePath = join7(worktreeDir, slug(`${name}-${sessionCode.slice(0, 8)}`));
7943
+ const base = await syncedBase();
7944
+ if (wasCanceled()) return log(`session #${sessionId} start canceled during git sync`);
7847
7945
  createWorktree({
7848
7946
  repo,
7849
7947
  worktreePath,
7850
7948
  sessionCode: sessionCode.slice(0, 8),
7851
- base: repoHeadBranch(repo)
7949
+ base
7852
7950
  });
7853
7951
  await client.sessions.materialize(sessionId, { agentSessionId, worktreePath }, cfg.apiToken);
7952
+ if (wasCanceled()) return log(`session #${sessionId} start canceled after materializing`);
7854
7953
  log(`materialized session #${sessionId} \u2192 ${worktreePath}`);
7855
7954
  } else if (!existsSync5(worktreePath)) {
7856
- restoreWorktree(repo, worktreePath, session2.agentSessionId.slice(0, 8));
7955
+ await restoreWorktree(repo, worktreePath, session2.agentSessionId.slice(0, 8), baseBranch);
7956
+ if (wasCanceled()) return log(`session #${sessionId} start canceled during worktree restore`);
7857
7957
  log(`recreated session #${sessionId} worktree (was missing) \u2192 ${worktreePath}`);
7858
7958
  }
7859
7959
  ensureExcluded(repo, PROJECT_CONFIG_NAME);
@@ -7862,7 +7962,23 @@ var createSessionSupervisor = (deps) => {
7862
7962
  return log(`session #${sessionId} terminal opened mid-start \u2014 skipping headless start`);
7863
7963
  spawnChild(sessionId, worktreePath);
7864
7964
  };
7965
+ const start = (sessionId, name) => {
7966
+ const epoch = currentStartEpoch(sessionId);
7967
+ const existing = starts.get(sessionId);
7968
+ if (existing?.epoch === epoch) return existing.promise;
7969
+ const pending = existing ? existing.promise.then(
7970
+ () => startOne(sessionId, name, epoch),
7971
+ () => startOne(sessionId, name, epoch)
7972
+ ) : startOne(sessionId, name, epoch);
7973
+ starts.set(sessionId, { epoch, promise: pending });
7974
+ const clear = () => {
7975
+ if (starts.get(sessionId)?.promise === pending) starts.delete(sessionId);
7976
+ };
7977
+ void pending.then(clear, clear);
7978
+ return pending;
7979
+ };
7865
7980
  const stop = (sessionId) => {
7981
+ cancelStart(sessionId);
7866
7982
  closeTerminal(sessionId);
7867
7983
  const entry = children.get(sessionId);
7868
7984
  if (!entry) return;
@@ -7871,6 +7987,7 @@ var createSessionSupervisor = (deps) => {
7871
7987
  log(`stopped session #${sessionId}`);
7872
7988
  };
7873
7989
  const remove = (sessionId, worktreePath) => {
7990
+ cancelStart(sessionId);
7874
7991
  closeTerminal(sessionId);
7875
7992
  const entry = children.get(sessionId);
7876
7993
  if (entry) {
@@ -8146,6 +8263,11 @@ var parseAgentKind = (value) => {
8146
8263
  if (raw === "claude-code" || raw === "codex") return raw;
8147
8264
  throw new Error(`invalid agent kind "${raw}" (expected claude-code or codex)`);
8148
8265
  };
8266
+ var nonEmptyString = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
8267
+ var resolveBaseBranch = (input) => {
8268
+ const branch = nonEmptyString(input.flag) ?? nonEmptyString(input.env) ?? nonEmptyString(input.saved) ?? repoHeadBranch(input.repo ?? process.cwd());
8269
+ return validateBaseBranch(branch);
8270
+ };
8149
8271
  var registerWorker = async (client, input, cfgPath = projectConfigPath()) => {
8150
8272
  const out = await client.workers.register({
8151
8273
  projectId: input.projectId,
@@ -8154,17 +8276,19 @@ var registerWorker = async (client, input, cfgPath = projectConfigPath()) => {
8154
8276
  name: input.name,
8155
8277
  hostname: input.hostname
8156
8278
  });
8157
- try {
8158
- loadProjectConfig(cfgPath);
8159
- } catch {
8160
- saveProjectConfig(cfgPath, { server: input.server, project: input.projectId });
8161
- }
8162
- setWorker(cfgPath, {
8163
- id: out.worker.id,
8164
- name: out.worker.name,
8165
- agentKind: out.worker.agentKind,
8166
- machineId: out.worker.machineId,
8167
- apiToken: out.apiToken
8279
+ const existing = loadProjectConfigOrNull(cfgPath) ?? {};
8280
+ saveProjectConfig(cfgPath, {
8281
+ ...existing,
8282
+ server: input.server,
8283
+ project: input.projectId,
8284
+ baseBranch: input.baseBranch,
8285
+ worker: {
8286
+ id: out.worker.id,
8287
+ name: out.worker.name,
8288
+ agentKind: out.worker.agentKind,
8289
+ machineId: out.worker.machineId,
8290
+ apiToken: out.apiToken
8291
+ }
8168
8292
  });
8169
8293
  return { out, configPath: cfgPath };
8170
8294
  };
@@ -8187,6 +8311,10 @@ var worker = defineCommand({
8187
8311
  type: "string",
8188
8312
  description: "agent engine for new sessions: claude-code or codex"
8189
8313
  },
8314
+ baseBranch: {
8315
+ type: "string",
8316
+ description: "branch new session worktrees start from (default: current branch, then main)"
8317
+ },
8190
8318
  ...common
8191
8319
  },
8192
8320
  run: async ({ args }) => {
@@ -8197,9 +8325,15 @@ var worker = defineCommand({
8197
8325
  );
8198
8326
  if (!args.json) console.log(`registering against ${server}`);
8199
8327
  const cfgPath = configPathFromArgs(args);
8200
- const fileToken = loadProjectConfigOrNull(cfgPath)?.worker?.apiToken;
8328
+ const savedConfig = loadProjectConfigOrNull(cfgPath);
8329
+ const fileToken = savedConfig?.worker?.apiToken;
8201
8330
  const auth = resolveAuth(process.env, fileToken);
8202
8331
  const c = createClient(server, auth && auth !== "cookie" ? auth : void 0);
8332
+ const baseBranch = resolveBaseBranch({
8333
+ flag: args.baseBranch,
8334
+ env: process.env.BATON_BASE_BRANCH,
8335
+ saved: savedConfig?.baseBranch
8336
+ });
8203
8337
  const hostname = osHostname();
8204
8338
  const machineId = readOrCreateMachineId();
8205
8339
  const name = args.name ?? hostname;
@@ -8212,16 +8346,18 @@ var worker = defineCommand({
8212
8346
  server,
8213
8347
  hostname,
8214
8348
  machineId,
8215
- agentKind
8349
+ agentKind,
8350
+ baseBranch
8216
8351
  },
8217
8352
  cfgPath
8218
8353
  );
8219
8354
  if (args.json) {
8220
- console.log(toJson({ ...out, configPath, machineIdPath: machineIdPath() }));
8355
+ console.log(toJson({ ...out, baseBranch, configPath, machineIdPath: machineIdPath() }));
8221
8356
  return;
8222
8357
  }
8223
8358
  console.log(`worker #${out.worker.id} (${out.worker.name}) \u2014 ${out.outcome}`);
8224
8359
  console.log(` agentKind: ${out.worker.agentKind}`);
8360
+ console.log(` baseBranch: ${baseBranch}`);
8225
8361
  console.log(` hostname: ${out.worker.hostname}`);
8226
8362
  console.log(` machineId: ${out.worker.machineId}`);
8227
8363
  console.log(` machineId file: ${machineIdPath()}`);
@@ -8330,13 +8466,22 @@ var worker = defineCommand({
8330
8466
  console.log("(no worker registered for this project \u2014 run `baton worker register`)");
8331
8467
  return;
8332
8468
  }
8469
+ const baseBranch = resolveBaseBranch({ saved: cfg.baseBranch });
8333
8470
  if (args.json) {
8334
- console.log(toJson({ ...worker2, server: cfg?.server, projectId: cfg?.project }));
8471
+ console.log(
8472
+ toJson({
8473
+ ...worker2,
8474
+ server: cfg.server,
8475
+ projectId: cfg.project,
8476
+ baseBranch
8477
+ })
8478
+ );
8335
8479
  return;
8336
8480
  }
8337
8481
  console.log(`worker #${worker2.id} (${worker2.name})`);
8338
8482
  console.log(` machineId: ${worker2.machineId}`);
8339
8483
  console.log(` agentKind: ${worker2.agentKind ?? "claude-code"}`);
8484
+ console.log(` baseBranch: ${baseBranch}`);
8340
8485
  console.log(` server: ${cfg?.server}`);
8341
8486
  }
8342
8487
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lesscap/baton-cli",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"