@geteigen/cli 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
- import { killCli, spawnCli } from "./proc.js";
1
+ import { killCli, spawnUserCli } from "./proc.js";
2
+ import { agentRunEnv } from "./cliEnv.js";
2
3
  import { createInterface } from "readline";
3
4
  import { existsSync, promises as fsPromises } from "fs";
4
5
  import { resolve, relative, isAbsolute } from "path";
@@ -51,7 +52,7 @@ runEnv) {
51
52
  : undefined;
52
53
  const start = async () => {
53
54
  // OpenCode resolves its project from $PWD, not the spawn cwd.
54
- const baseEnv = { ...process.env, ...(runEnv || {}), PWD: repoPath };
55
+ const baseEnv = { ...agentRunEnv(runEnv), PWD: repoPath };
55
56
  let resumeId = sessionId || null;
56
57
  if (kind === "opencode" &&
57
58
  resumeId &&
@@ -69,7 +70,7 @@ runEnv) {
69
70
  const args = backend.buildArgs(buildOpts);
70
71
  const backendEnv = backend.buildEnv?.(buildOpts);
71
72
  const mounted = await mountCursorProjectMcp(repoPath, cursorMcp);
72
- const child = spawnCli(backend.command, args, {
73
+ const child = spawnUserCli(backend.command, args, {
73
74
  cwd: repoPath,
74
75
  env: { ...baseEnv, ...(backendEnv || {}), ...mounted.env },
75
76
  stdio: ["ignore", "pipe", "pipe"],
@@ -131,6 +132,8 @@ runEnv) {
131
132
  return;
132
133
  }
133
134
  for (const event of backend.parseLine(ev, ctx)) {
135
+ if (event.kind === "tool_start")
136
+ ctx.thinkingBuf.value = "";
134
137
  onEvent(event);
135
138
  }
136
139
  });
@@ -1,10 +1,6 @@
1
1
  import type { MachineAgentTool } from "../../shared/machines.js";
2
- /**
3
- * Which agent CLIs this machine can run. Shared by the CLI worker (reported
4
- * to the server on connect) and the desktop app (Machines page).
5
- */
6
2
  /** Built per call so a PATH merged after import (desktop login shell) is used. */
7
3
  export declare function noColorEnv(): NodeJS.ProcessEnv;
8
- /** Absolute path of `command` on the current PATH, like `which`. */
4
+ /** Absolute path of `command` on PATH plus well-known install dirs. */
9
5
  export declare function findOnPath(command: string): string | null;
10
6
  export declare function detectAgentTools(): Promise<MachineAgentTool[]>;
@@ -6,17 +6,39 @@ import { execCli } from "./proc.js";
6
6
  * Which agent CLIs this machine can run. Shared by the CLI worker (reported
7
7
  * to the server on connect) and the desktop app (Machines page).
8
8
  */
9
+ function extraBinDirs() {
10
+ const home = homedir();
11
+ return [
12
+ join(home, ".local", "bin"),
13
+ join(home, ".opencode", "bin"),
14
+ join(home, ".claude", "local"),
15
+ join(home, ".cursor", "bin"),
16
+ "/usr/local/bin",
17
+ ];
18
+ }
9
19
  /** Built per call so a PATH merged after import (desktop login shell) is used. */
10
20
  export function noColorEnv() {
11
- return { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" };
21
+ const extra = extraBinDirs().join(delimiter);
22
+ const path = process.env.PATH || "";
23
+ return {
24
+ ...process.env,
25
+ PATH: extra ? `${extra}${delimiter}${path}` : path,
26
+ NO_COLOR: "1",
27
+ FORCE_COLOR: "0",
28
+ };
12
29
  }
13
30
  const WINDOWS_EXTS = [".exe", ".cmd", ".bat", ""];
14
- /** Absolute path of `command` on the current PATH, like `which`. */
31
+ function dirsToSearch() {
32
+ return [...extraBinDirs(), ...(process.env.PATH || "").split(delimiter)];
33
+ }
34
+ /** Absolute path of `command` on PATH plus well-known install dirs. */
15
35
  export function findOnPath(command) {
16
36
  const exts = process.platform === "win32" ? WINDOWS_EXTS : [""];
17
- for (const dir of (process.env.PATH || "").split(delimiter)) {
18
- if (!dir)
37
+ const seen = new Set();
38
+ for (const dir of dirsToSearch()) {
39
+ if (!dir || seen.has(dir))
19
40
  continue;
41
+ seen.add(dir);
20
42
  for (const ext of exts) {
21
43
  const candidate = join(dir, command + ext);
22
44
  try {
@@ -50,21 +72,33 @@ async function versionOf(command, args) {
50
72
  }
51
73
  }
52
74
  const TOOLS = [
53
- { id: "cursor", label: "Cursor", command: "cursor", args: ["agent", "--version"] },
54
- { id: "claude", label: "Claude Code", command: "claude", args: ["--version"] },
55
- { id: "codex", label: "Codex", command: "codex", args: ["--version"] },
56
- { id: "opencode", label: "OpenCode", command: "opencode", args: ["--version"] },
75
+ {
76
+ id: "cursor",
77
+ label: "Cursor",
78
+ binaries: [
79
+ { command: "cursor", args: ["agent", "--version"] },
80
+ { command: "agent", args: ["--version"] },
81
+ ],
82
+ },
83
+ { id: "claude", label: "Claude Code", binaries: [{ command: "claude", args: ["--version"] }] },
84
+ { id: "codex", label: "Codex", binaries: [{ command: "codex", args: ["--version"] }] },
85
+ { id: "opencode", label: "OpenCode", binaries: [{ command: "opencode", args: ["--version"] }] },
57
86
  ];
58
87
  export async function detectAgentTools() {
59
88
  return Promise.all(TOOLS.map(async (tool) => {
60
- const version = await versionOf(tool.command, tool.args);
61
- const path = findOnPath(tool.command);
62
- return {
63
- id: tool.id,
64
- label: tool.label,
65
- installed: Boolean(version),
66
- ...(version ? { version } : {}),
67
- ...(path ? { path: displayPath(path) } : {}),
68
- };
89
+ for (const bin of tool.binaries) {
90
+ const version = await versionOf(bin.command, bin.args);
91
+ if (!version)
92
+ continue;
93
+ const path = findOnPath(bin.command);
94
+ return {
95
+ id: tool.id,
96
+ label: tool.label,
97
+ installed: true,
98
+ version,
99
+ ...(path ? { path: displayPath(path) } : {}),
100
+ };
101
+ }
102
+ return { id: tool.id, label: tool.label, installed: false };
69
103
  }));
70
104
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Env for spawning `claude` / `codex` / `cursor` from Eigen.
3
+ * Empty provider keys and `CI=true` make Claude Code skip a working
4
+ * Terminal/Foundry login and print "Not logged in · Please run /login".
5
+ */
6
+ export declare function agentRunEnv(runEnv?: Record<string, string>): NodeJS.ProcessEnv;
@@ -0,0 +1,26 @@
1
+ import { homedir } from "os";
2
+ import { storedGithubCredentialEnv } from "./gitAuthEnv.js";
3
+ const EMPTY_OVERRIDE_KEYS = [
4
+ "ANTHROPIC_API_KEY",
5
+ "ANTHROPIC_AUTH_TOKEN",
6
+ "CLAUDE_CODE_OAUTH_TOKEN",
7
+ "OPENAI_API_KEY",
8
+ "CODEX_API_KEY",
9
+ ];
10
+ /**
11
+ * Env for spawning `claude` / `codex` / `cursor` from Eigen.
12
+ * Empty provider keys and `CI=true` make Claude Code skip a working
13
+ * Terminal/Foundry login and print "Not logged in · Please run /login".
14
+ */
15
+ export function agentRunEnv(runEnv) {
16
+ const env = { ...process.env, ...(runEnv || {}) };
17
+ if (!env.HOME?.trim())
18
+ env.HOME = homedir();
19
+ for (const key of EMPTY_OVERRIDE_KEYS) {
20
+ if (typeof env[key] === "string" && !env[key].trim())
21
+ delete env[key];
22
+ }
23
+ if (!env.ANTHROPIC_API_KEY?.trim())
24
+ delete env.CI;
25
+ return { ...env, ...storedGithubCredentialEnv() };
26
+ }
@@ -0,0 +1,5 @@
1
+ export declare function gitCredentialsPath(): string;
2
+ /** Persist GitHub HTTPS creds for this machine (0600). Not written into origin URLs. */
3
+ export declare function rememberGithubToken(token: string): void;
4
+ /** Git env so pull/fetch/push use the stored helper without prompting. */
5
+ export declare function storedGithubCredentialEnv(): Record<string, string>;
@@ -0,0 +1,35 @@
1
+ import { chmodSync, existsSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { getConfigDir } from "./config.js";
4
+ export function gitCredentialsPath() {
5
+ return join(getConfigDir(), "git-credentials");
6
+ }
7
+ /** Persist GitHub HTTPS creds for this machine (0600). Not written into origin URLs. */
8
+ export function rememberGithubToken(token) {
9
+ const trimmed = token.trim();
10
+ if (!trimmed)
11
+ return;
12
+ const file = gitCredentialsPath();
13
+ writeFileSync(file, `https://x-access-token:${encodeURIComponent(trimmed)}@github.com\n`, {
14
+ encoding: "utf8",
15
+ mode: 0o600,
16
+ });
17
+ try {
18
+ chmodSync(file, 0o600);
19
+ }
20
+ catch {
21
+ // some filesystems ignore mode
22
+ }
23
+ }
24
+ /** Git env so pull/fetch/push use the stored helper without prompting. */
25
+ export function storedGithubCredentialEnv() {
26
+ const file = gitCredentialsPath();
27
+ if (!existsSync(file))
28
+ return { GIT_TERMINAL_PROMPT: "0" };
29
+ return {
30
+ GIT_TERMINAL_PROMPT: "0",
31
+ GIT_CONFIG_COUNT: "1",
32
+ GIT_CONFIG_KEY_0: "credential.helper",
33
+ GIT_CONFIG_VALUE_0: `store --file=${file}`,
34
+ };
35
+ }
@@ -5,6 +5,14 @@ import type { ChildProcess, SpawnOptions } from "child_process";
5
5
  * cross-spawn resolves the shim and escapes arguments safely.
6
6
  */
7
7
  export declare function spawnCli(command: string, args: string[], options: SpawnOptions): ChildProcess;
8
+ /** POSIX-safe single quotes so prompts / MCP JSON survive `zsh -lc`. */
9
+ export declare function posixShellQuote(value: string): string;
10
+ /**
11
+ * Spawn an agent CLI the way a terminal would. Finder/Dock Eigen has a
12
+ * different keychain context than Terminal; a login shell is how Claude
13
+ * Foundry / Codex subscription creds actually resolve.
14
+ */
15
+ export declare function spawnUserCli(command: string, args: string[], options: SpawnOptions): ChildProcess;
8
16
  export interface ExecCliOptions {
9
17
  cwd?: string;
10
18
  env?: NodeJS.ProcessEnv;
@@ -7,6 +7,26 @@ import crossSpawn from "cross-spawn";
7
7
  export function spawnCli(command, args, options) {
8
8
  return crossSpawn(command, args, options);
9
9
  }
10
+ /** POSIX-safe single quotes so prompts / MCP JSON survive `zsh -lc`. */
11
+ export function posixShellQuote(value) {
12
+ return `'${value.replace(/'/g, `'\\''`)}'`;
13
+ }
14
+ /**
15
+ * Spawn an agent CLI the way a terminal would. Finder/Dock Eigen has a
16
+ * different keychain context than Terminal; a login shell is how Claude
17
+ * Foundry / Codex subscription creds actually resolve.
18
+ */
19
+ export function spawnUserCli(command, args, options) {
20
+ if (process.platform === "win32")
21
+ return spawnCli(command, args, options);
22
+ const shell = process.env.SHELL && process.env.SHELL.startsWith("/")
23
+ ? process.env.SHELL
24
+ : process.platform === "darwin"
25
+ ? "/bin/zsh"
26
+ : "/bin/sh";
27
+ const line = [command, ...args].map(posixShellQuote).join(" ");
28
+ return spawnCli(shell, ["-lc", line], options);
29
+ }
10
30
  /** execFile-style helper on top of spawnCli: resolves `{ stdout, stderr }`, rejects on non-zero exit. */
11
31
  export function execCli(command, args, options = {}) {
12
32
  const maxBuffer = options.maxBuffer ?? 1024 * 1024;
@@ -145,7 +145,16 @@ export function installService(startArgs) {
145
145
  throw new Error(enabled.out || "systemctl enable failed");
146
146
  const notes = [`Installed ${path}`, `Logs: ${system ? `journalctl -u ${UNIT} -f` : `journalctl --user -u ${UNIT} -f`}`];
147
147
  if (!system) {
148
- notes.push(`To keep it running after you log out: sudo loginctl enable-linger ${process.env.USER || "$USER"}`);
148
+ const lingerUser = process.env.SUDO_USER || process.env.USER || "";
149
+ const linger = lingerUser
150
+ ? run("sudo", ["-n", "loginctl", "enable-linger", lingerUser])
151
+ : { ok: false, out: "" };
152
+ if (linger.ok) {
153
+ notes.push(`Linger enabled for ${lingerUser} (survives SSH logout).`);
154
+ }
155
+ else {
156
+ notes.push(`To keep it running after you log out: sudo loginctl enable-linger ${lingerUser || "$USER"}`);
157
+ }
149
158
  }
150
159
  return notes;
151
160
  }
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, realpathSync, statSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from "path";
4
4
  import { execCli } from "./proc.js";
5
+ import { gitCredentialsPath, rememberGithubToken } from "./gitAuthEnv.js";
5
6
  /**
6
7
  * Folders this machine lets agents work in. Local roots come from
7
8
  * `eigen start --root` / EIGEN_WORK_ROOT; server roots from Eigen → Machines.
@@ -106,11 +107,12 @@ function authedUrl(repoUrl, token) {
106
107
  return `${clean}.git`;
107
108
  return `${clean.replace(/^https:\/\//, `https://x-access-token:${encodeURIComponent(token)}@`)}.git`;
108
109
  }
109
- /**
110
- * The token is only needed while talking to GitHub. Put the plain URL back so
111
- * it never stays in .git/config, where anyone else on a shared machine could
112
- * read it.
113
- */
110
+ async function useStoredGithubCreds(cwd) {
111
+ const file = gitCredentialsPath();
112
+ await git(cwd, ["config", "--local", "--unset-all", "credential.helper"]).catch(() => undefined);
113
+ await git(cwd, ["config", "--local", "credential.helper", `store --file=${file}`]);
114
+ }
115
+ /** Don't leave the access token in origin — `git remote -v` prints it. */
114
116
  async function forgetRemoteToken(cwd) {
115
117
  try {
116
118
  const current = (await git(cwd, ["remote", "get-url", "origin"])).stdout.trim();
@@ -122,6 +124,13 @@ async function forgetRemoteToken(cwd) {
122
124
  // no origin / not a repo
123
125
  }
124
126
  }
127
+ async function persistGithubAuth(cwd, token) {
128
+ if (!token || !existsSync(join(cwd, ".git")))
129
+ return;
130
+ rememberGithubToken(token);
131
+ await useStoredGithubCreds(cwd);
132
+ await forgetRemoteToken(cwd);
133
+ }
125
134
  /** Strip credentials so errors never echo a token. */
126
135
  function scrub(message, token) {
127
136
  let out = message.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@");
@@ -173,8 +182,7 @@ export async function prepareRepo(roots, input) {
173
182
  throw new Error(scrub(err.stderr || err.message, input.token));
174
183
  }
175
184
  finally {
176
- if (input.token && existsSync(join(target, ".git")))
177
- await forgetRemoteToken(target);
185
+ await persistGithubAuth(target, input.token);
178
186
  }
179
187
  }
180
188
  export async function finalizeGit(roots, input) {
@@ -215,7 +223,6 @@ export async function finalizeGit(roots, input) {
215
223
  throw new Error(scrub(err.stderr || err.message, input.token));
216
224
  }
217
225
  finally {
218
- if (input.token)
219
- await forgetRemoteToken(cwd);
226
+ await persistGithubAuth(cwd, input.token);
220
227
  }
221
228
  }
@@ -7,8 +7,9 @@ import { dirname, resolve } from "path";
7
7
  import { listLocalModelsForBackend } from "./listModels.js";
8
8
  import { runAgentWithHandle, abortRun, abortAll, isEditTool, getFileDiff, revertFiles, } from "./agent.js";
9
9
  import { listChatSessions } from "./listSessions.js";
10
+ import { rememberGithubToken } from "./gitAuthEnv.js";
10
11
  import { WorkRoots, finalizeGit, prepareRepo } from "./workRoots.js";
11
- import { checkoutWorkspaceGit, commitWorkspaceGit, getWorkspaceGit, listWorkspaceDir, pushWorkspaceGit, readWorkspaceFile, searchWorkspaceFiles, } from "./workspace.js";
12
+ import { checkoutWorkspaceGit, commitWorkspaceGit, createWorkspaceGitBranch, discardWorkspaceGit, getWorkspaceGit, listWorkspaceDir, pushWorkspaceGit, readWorkspaceFile, searchWorkspaceFiles, } from "./workspace.js";
12
13
  import { isSafeAttachmentRelPath, } from "../../shared/uploads.js";
13
14
  /**
14
15
  * 6: worker:list-models accepts optional `backend` (cursor | claude-code | codex).
@@ -110,13 +111,28 @@ export function createWorker(options) {
110
111
  socket.emit(item.event, item.payload);
111
112
  }
112
113
  };
113
- // Which agent CLIs this machine has; refreshed on each (re)connect.
114
+ // Which agent CLIs this machine has. First report is on connect; rescan
115
+ // while connected so installing CLIs later shows up without a restart.
114
116
  let agentTools = [];
117
+ let lastToolsJson = "";
115
118
  const refreshAgentTools = () => detectAgentTools()
116
119
  .then((tools) => {
117
120
  agentTools = tools;
118
121
  })
119
122
  .catch(() => undefined);
123
+ const reportAgentTools = async () => {
124
+ await refreshAgentTools();
125
+ const json = JSON.stringify(agentTools);
126
+ if (json === lastToolsJson)
127
+ return;
128
+ lastToolsJson = json;
129
+ if (socket.connected)
130
+ socket.emit("worker:info", { agents: agentTools });
131
+ };
132
+ const toolsTimer = setInterval(() => {
133
+ void reportAgentTools();
134
+ }, 60_000);
135
+ toolsTimer.unref?.();
120
136
  const announceReady = () => {
121
137
  const runs = listActiveRuns();
122
138
  socket.emit("worker:ready", {
@@ -141,11 +157,7 @@ export function createWorker(options) {
141
157
  log.info(` ${activeRuns.size} agent run(s) still going — resuming stream`);
142
158
  }
143
159
  announceReady();
144
- // Tool detection can take a few seconds; send it once it's in.
145
- void refreshAgentTools().then(() => {
146
- if (socket.connected)
147
- socket.emit("worker:info", { agents: agentTools });
148
- });
160
+ void reportAgentTools();
149
161
  flushQueue();
150
162
  });
151
163
  socket.on("disconnect", (reason) => {
@@ -575,12 +587,18 @@ export function createWorker(options) {
575
587
  try {
576
588
  if (!roots.isAllowed(payload.repoPath))
577
589
  throw new Error("That folder is outside this machine's allowed folders");
590
+ if (payload.token)
591
+ rememberGithubToken(payload.token);
578
592
  if (payload.action === "commit")
579
593
  await commitWorkspaceGit(payload.repoPath, payload.message || "");
580
594
  else if (payload.action === "push")
581
595
  await pushWorkspaceGit(payload.repoPath);
582
596
  else if (payload.action === "checkout")
583
597
  await checkoutWorkspaceGit(payload.repoPath, payload.branch || "");
598
+ else if (payload.action === "create")
599
+ await createWorkspaceGitBranch(payload.repoPath, payload.branch || "");
600
+ else if (payload.action === "discard")
601
+ await discardWorkspaceGit(payload.repoPath, payload.path || "");
584
602
  else
585
603
  throw new Error("Unknown git action");
586
604
  emitOrQueue("worker:workspace-git-action-result", { requestId: payload.requestId, ok: true });
@@ -694,6 +712,7 @@ export function createWorker(options) {
694
712
  workerId,
695
713
  status,
696
714
  stop() {
715
+ clearInterval(toolsTimer);
697
716
  abortAll();
698
717
  activeRuns.clear();
699
718
  socket.disconnect();
@@ -3,6 +3,8 @@ export declare function getWorkspaceGit(repoPath: string): Promise<WorkspaceGitS
3
3
  export declare function commitWorkspaceGit(repoPath: string, rawMessage: string): Promise<void>;
4
4
  export declare function pushWorkspaceGit(repoPath: string): Promise<void>;
5
5
  export declare function checkoutWorkspaceGit(repoPath: string, rawBranch: string): Promise<void>;
6
+ export declare function createWorkspaceGitBranch(repoPath: string, rawBranch: string): Promise<void>;
7
+ export declare function discardWorkspaceGit(repoPath: string, rawPath?: string): Promise<void>;
6
8
  export declare function listWorkspaceDir(repoPath: string, rawPath: string): Promise<WorkspaceRepoEntry[]>;
7
9
  export declare function readWorkspaceFile(repoPath: string, rawPath: string): Promise<WorkspaceRepoFile>;
8
10
  export declare function searchWorkspaceFiles(repoPath: string, query: string): Promise<WorkspacePathMatch[]>;
@@ -1,7 +1,8 @@
1
1
  import { existsSync, promises as fs } from "fs";
2
2
  import { isAbsolute, relative, resolve } from "path";
3
3
  import { execCli } from "./proc.js";
4
- import { assertCommitMessage, assertGitBranchName, parseGitBranches, } from "../../shared/workspace.js";
4
+ import { storedGithubCredentialEnv } from "./gitAuthEnv.js";
5
+ import { assertCommitMessage, assertGitBranchName, assertGitChangePath, parseGitBranches, } from "../../shared/workspace.js";
5
6
  const MAX_GIT_DIFF_CHARS = 400_000;
6
7
  const MAX_REPO_FILE_BYTES = 512 * 1024;
7
8
  const MAX_REPO_IMAGE_BYTES = 3 * 1024 * 1024;
@@ -36,7 +37,7 @@ async function git(repoPath, args) {
36
37
  cwd: repoPath,
37
38
  timeout: 30_000,
38
39
  maxBuffer: 8 * 1024 * 1024,
39
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
40
+ env: { ...process.env, ...storedGithubCredentialEnv() },
40
41
  });
41
42
  return stdout;
42
43
  }
@@ -56,12 +57,15 @@ function resolveInside(repoPath, rawPath) {
56
57
  export async function getWorkspaceGit(repoPath) {
57
58
  if (!repoPath || !existsSync(repoPath))
58
59
  throw new Error("Repository folder not found");
59
- const branchRaw = await git(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
60
- const statusRaw = await git(repoPath, ["status", "--porcelain"]);
61
- const logRaw = await git(repoPath, ["log", "-n", "25", "--pretty=format:%h%x09%s%x09%an%x09%cr"]);
62
- const aheadRaw = (await git(repoPath, ["rev-list", "--count", "@{u}..HEAD"])).trim();
63
- const branchList = await git(repoPath, ["branch", "-a", "--format=%(refname:short)"]);
64
- const diffRaw = await git(repoPath, ["diff", "HEAD"]);
60
+ const [branchRaw, statusRaw, logRaw, aheadCount, branchList, diffRaw] = await Promise.all([
61
+ git(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
62
+ git(repoPath, ["status", "--porcelain"]),
63
+ git(repoPath, ["log", "-n", "25", "--pretty=format:%h%x09%s%x09%an%x09%cr"]),
64
+ git(repoPath, ["rev-list", "--count", "@{u}..HEAD"]),
65
+ git(repoPath, ["branch", "-a", "--format=%(refname:short)"]),
66
+ git(repoPath, ["diff", "HEAD"]),
67
+ ]);
68
+ const aheadRaw = aheadCount.trim();
65
69
  const changes = statusRaw
66
70
  .split("\n")
67
71
  .filter((line) => line.trim())
@@ -90,7 +94,7 @@ async function gitChecked(repoPath, args, timeout = 60_000) {
90
94
  cwd: repoPath,
91
95
  timeout,
92
96
  maxBuffer: 8 * 1024 * 1024,
93
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
97
+ env: { ...process.env, ...storedGithubCredentialEnv() },
94
98
  });
95
99
  return stdout;
96
100
  }
@@ -99,17 +103,45 @@ async function gitChecked(repoPath, args, timeout = 60_000) {
99
103
  throw new Error((e.stderr || e.message || "Git failed").trim().slice(0, 800));
100
104
  }
101
105
  }
106
+ async function githubCliIdentity() {
107
+ try {
108
+ const { stdout } = await execCli("gh", ["api", "user"], { timeout: 15_000 });
109
+ const user = JSON.parse(stdout);
110
+ const login = String(user.login || "").trim();
111
+ if (!login)
112
+ return null;
113
+ const email = typeof user.email === "string" && user.email.includes("@")
114
+ ? user.email.trim()
115
+ : `${user.id ? `${user.id}+` : ""}${login}@users.noreply.github.com`;
116
+ return { name: String(user.name || "").trim() || login, email };
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ /** Prefer the machine git identity, then the signed-in GitHub CLI user. */
123
+ async function resolveCommitIdentity(repoPath) {
124
+ const name = (await git(repoPath, ["config", "--get", "user.name"])).trim();
125
+ const email = (await git(repoPath, ["config", "--get", "user.email"])).trim();
126
+ if (name && email)
127
+ return { name, email };
128
+ const gh = await githubCliIdentity();
129
+ if (gh)
130
+ return { name: name || gh.name, email: email || gh.email };
131
+ throw new Error("Set git user.name and user.email, or sign in with GitHub CLI (gh auth login)");
132
+ }
102
133
  export async function commitWorkspaceGit(repoPath, rawMessage) {
103
134
  const message = assertCommitMessage(rawMessage);
135
+ const author = await resolveCommitIdentity(repoPath);
104
136
  await gitChecked(repoPath, ["add", "-A"]);
105
137
  const status = await git(repoPath, ["status", "--porcelain"]);
106
138
  if (!status.trim())
107
139
  throw new Error("Nothing to commit");
108
140
  await gitChecked(repoPath, [
109
141
  "-c",
110
- "user.name=Eigen",
142
+ `user.name=${author.name}`,
111
143
  "-c",
112
- "user.email=agent@users.noreply.github.com",
144
+ `user.email=${author.email}`,
113
145
  "commit",
114
146
  "-m",
115
147
  message,
@@ -119,6 +151,7 @@ export async function pushWorkspaceGit(repoPath) {
119
151
  const branch = (await git(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
120
152
  if (!branch || branch === "HEAD")
121
153
  throw new Error("Checkout a branch before pushing");
154
+ await gitChecked(repoPath, ["fetch", "origin"], 180_000).catch(() => undefined);
122
155
  await gitChecked(repoPath, ["push", "-u", "origin", "HEAD"], 180_000);
123
156
  }
124
157
  export async function checkoutWorkspaceGit(repoPath, rawBranch) {
@@ -136,6 +169,33 @@ export async function checkoutWorkspaceGit(repoPath, rawBranch) {
136
169
  await gitChecked(repoPath, ["checkout", "-B", branch, `origin/${branch}`]);
137
170
  }
138
171
  }
172
+ export async function createWorkspaceGitBranch(repoPath, rawBranch) {
173
+ const branch = assertGitBranchName(rawBranch);
174
+ const current = (await git(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
175
+ if (current === branch)
176
+ return;
177
+ const known = parseGitBranches(await git(repoPath, ["branch", "-a", "--format=%(refname:short)"]), current);
178
+ if (known.includes(branch))
179
+ throw new Error("That branch already exists");
180
+ await gitChecked(repoPath, ["checkout", "-b", branch]);
181
+ }
182
+ export async function discardWorkspaceGit(repoPath, rawPath = "") {
183
+ const path = assertGitChangePath(rawPath);
184
+ if (!path) {
185
+ await gitChecked(repoPath, ["reset", "--hard", "HEAD"]);
186
+ await gitChecked(repoPath, ["clean", "-fd"]);
187
+ return;
188
+ }
189
+ const tracked = await git(repoPath, ["ls-files", "--error-unmatch", "--", path]);
190
+ if (tracked.trim()) {
191
+ await gitChecked(repoPath, ["restore", "--source=HEAD", "--staged", "--worktree", "--", path]).catch(async () => {
192
+ await gitChecked(repoPath, ["checkout", "HEAD", "--", path]);
193
+ });
194
+ return;
195
+ }
196
+ const { abs } = resolveInside(repoPath, path);
197
+ await fs.rm(abs, { recursive: true, force: true });
198
+ }
139
199
  export async function listWorkspaceDir(repoPath, rawPath) {
140
200
  const { abs, rel } = resolveInside(repoPath, rawPath);
141
201
  let dir;
@@ -68,6 +68,8 @@ export interface AutomationPromptInput {
68
68
  artifactRules?: string;
69
69
  /** The automation's memory doc: read first, log to before finishing. */
70
70
  trackingDoc?: TrackingDocRef | null;
71
+ /** When the Eigen GitHub App is installed, force PR comments through it. */
72
+ githubAppComments?: boolean;
71
73
  approvedToolCalls?: Array<{
72
74
  toolName: string;
73
75
  detail: string;
@@ -79,11 +81,7 @@ export interface TrackingDocRef {
79
81
  title: string;
80
82
  url: string;
81
83
  }
82
- /**
83
- * How a run uses its automation's tracking doc. The doc is the memory
84
- * between runs for both fresh agents and room agents.
85
- */
86
- export declare function trackingDocRules(doc: TrackingDocRef): string;
84
+ export declare function githubAppCommentRules(): string;
87
85
  /**
88
86
  * The turn an automation sends to the room agent it runs as. The agent keeps
89
87
  * its conversation, so this is short: what to do now, plus any event data.
@@ -95,6 +93,7 @@ export declare function buildRoomRunMessage(input: {
95
93
  event: unknown;
96
94
  mode?: "auto" | "approval" | "suggest_only";
97
95
  trackingDoc?: TrackingDocRef | null;
96
+ githubAppComments?: boolean;
98
97
  }): string;
99
98
  /** Sent to a resumed session after a paused write was approved. */
100
99
  export declare function buildApprovalContinuationPrompt(approvedToolCalls: Array<{
@@ -223,6 +223,7 @@ export function buildAutomationPrompt(input) {
223
223
  "- If you create or update a Doc, include its link in that final summary as a Markdown link, e.g. [Daily Reddit leads](/docs/doc_abc123).",
224
224
  ...(input.artifactRules ? ["", input.artifactRules] : []),
225
225
  ...(input.trackingDoc ? ["", trackingDocRules(input.trackingDoc)] : []),
226
+ ...(input.githubAppComments ? ["", githubAppCommentRules()] : []),
226
227
  "",
227
228
  "<untrusted_event>",
228
229
  safeJson(input.event),
@@ -239,11 +240,16 @@ export function buildAutomationPrompt(input) {
239
240
  "</steer_automation>",
240
241
  ].join("\n");
241
242
  }
242
- /**
243
- * How a run uses its automation's tracking doc. The doc is the memory
244
- * between runs for both fresh agents and room agents.
245
- */
246
- export function trackingDocRules(doc) {
243
+ export function githubAppCommentRules() {
244
+ return [
245
+ "<github_comments>",
246
+ "To comment on a GitHub pull request, call `steer_github_comment_pr` (steer_room MCP) with the PR url or owner/repo/number and the markdown body.",
247
+ "That posts as the Eigen GitHub App. Do not use steer_hub GitHub tools to comment, review, commit, create files, or push — those act as a personal GitHub user.",
248
+ "This run has no git checkout. Do not try to git commit or git push.",
249
+ "</github_comments>",
250
+ ].join("\n");
251
+ }
252
+ function trackingDocRules(doc) {
247
253
  return [
248
254
  "<tracking_doc>",
249
255
  `This automation's memory is the Doc "${doc.title}" (id ${doc.id}, link ${doc.url}).`,
@@ -278,6 +284,7 @@ export function buildRoomRunMessage(input) {
278
284
  : []),
279
285
  ...(input.trackingDoc ? ["", trackingDocRules(input.trackingDoc)] : []),
280
286
  "",
287
+ ...(input.githubAppComments ? ["", githubAppCommentRules()] : []),
281
288
  writes,
282
289
  "Use what you know from earlier runs in this conversation to avoid repeating work. End with a short summary of the result; link any Doc you created or updated.",
283
290
  ].join("\n");
@@ -1,4 +1,6 @@
1
1
  import type { BuildArgsOptions, NormalizedAgentEvent, ParseLineContext, WorkerBackend } from "./types.js";
2
+ /** Eigen's GUI worker often cannot see a Terminal/Foundry `claude auth` session. */
3
+ export declare function rewriteClaudeCliError(message: string): string;
2
4
  /**
3
5
  * Claude Code CLI headless backend.
4
6
  * Spawns: `claude -p --output-format stream-json --verbose ...`
@@ -1,6 +1,14 @@
1
1
  import { diffFromToolArgs, extractThinkingText, formatToolResultDetail, isEditTool, isQuestionTool, isTodoTool, parseQuestionToolArgs, todoStatusSummary, todosFromToolArgs, TOOL_RESULT_DETAIL_LIMIT, stringifyUnknown, } from "./cursor.js";
2
2
  import { claudeToolIntegration } from "../toolIntegration.js";
3
3
  import { claudeResultUsage } from "./usage.js";
4
+ /** Eigen's GUI worker often cannot see a Terminal/Foundry `claude auth` session. */
5
+ export function rewriteClaudeCliError(message) {
6
+ if (!/not logged in|please run \/login/i.test(message))
7
+ return message;
8
+ return ("Claude Code has no usable login in Eigen's worker. " +
9
+ "`claude auth status` in Terminal can still show logged in (Foundry / third-party). " +
10
+ "Quit Eigen completely and reopen it, or run `claude auth login` in Terminal while Eigen is open.");
11
+ }
4
12
  function extractText(message) {
5
13
  if (!message || typeof message !== "object")
6
14
  return "";
@@ -350,11 +358,11 @@ export class ClaudeCodeBackend {
350
358
  ctx.gotTerminalEvent.value = true;
351
359
  out.push({
352
360
  kind: "error",
353
- message: stringifyUnknown(ev.error) ||
361
+ message: rewriteClaudeCliError(stringifyUnknown(ev.error) ||
354
362
  stringifyUnknown(ev.message) ||
355
363
  stringifyUnknown(ev.result) ||
356
364
  ctx.stderr ||
357
- "Claude Code error",
365
+ "Claude Code error"),
358
366
  });
359
367
  return out;
360
368
  }
@@ -375,11 +383,11 @@ export class ClaudeCodeBackend {
375
383
  subtype === "error_during_execution" ||
376
384
  subtype === "error_max_turns";
377
385
  if (isError) {
378
- const msg = stringifyUnknown(ev.result) ||
386
+ const msg = rewriteClaudeCliError(stringifyUnknown(ev.result) ||
379
387
  stringifyUnknown(ev.errors) ||
380
388
  stringifyUnknown(ev.error) ||
381
389
  ctx.stderr ||
382
- `Claude Code error (${subtype || "unknown"})`;
390
+ `Claude Code error (${subtype || "unknown"})`);
383
391
  out.push({ kind: "error", message: msg });
384
392
  }
385
393
  else {
@@ -22,6 +22,15 @@ export declare function mergeAssistantText(prev: string, incoming: string): stri
22
22
  export declare function normalizeThinkingText(text: string): string;
23
23
  /** Merge streamed thinking: snapshots replace, deltas append with a space. */
24
24
  export declare function mergeThinkingText(prev: string, incoming: string): string;
25
+ /**
26
+ * Cursor CLI streams thinking as phrase-sized JSON lines that are not prefixes
27
+ * of each other. Always concatenate; a new thought starts only after tools
28
+ * (`afterTools` in RoomManager).
29
+ */
30
+ export declare function advanceThinking(prev: string, incoming: string): {
31
+ finalize?: string;
32
+ current: string;
33
+ };
25
34
  /** Pull visible model thinking / reasoning out of a CLI or SDK payload. */
26
35
  export declare function extractThinkingText(source: unknown): string;
27
36
  /**
@@ -192,6 +192,14 @@ export function mergeThinkingText(prev, incoming) {
192
192
  return base;
193
193
  return `${base} ${chunk}`;
194
194
  }
195
+ /**
196
+ * Cursor CLI streams thinking as phrase-sized JSON lines that are not prefixes
197
+ * of each other. Always concatenate; a new thought starts only after tools
198
+ * (`afterTools` in RoomManager).
199
+ */
200
+ export function advanceThinking(prev, incoming) {
201
+ return { current: mergeThinkingText(prev, incoming) };
202
+ }
195
203
  function thinkingFromRecord(rec) {
196
204
  for (const key of ["thinking", "reasoning", "text", "content"]) {
197
205
  const value = rec[key];
@@ -1074,16 +1082,20 @@ export class CursorAgentBackend {
1074
1082
  if (!text)
1075
1083
  return out;
1076
1084
  ctx.thinkingBuf ??= { value: "" };
1077
- const merged = mergeThinkingText(ctx.thinkingBuf.value, text);
1078
- if (merged === ctx.thinkingBuf.value)
1085
+ const step = advanceThinking(ctx.thinkingBuf.value, text);
1086
+ if (step.finalize) {
1087
+ out.push({ kind: "thinking_final", text: step.finalize });
1088
+ }
1089
+ if (step.current === ctx.thinkingBuf.value && !step.finalize)
1079
1090
  return out;
1080
- ctx.thinkingBuf.value = merged;
1091
+ ctx.thinkingBuf.value = step.current;
1092
+ const done = ev.subtype === "completed";
1081
1093
  out.push({
1082
- kind: ev.subtype === "completed" || type.endsWith("Message")
1083
- ? "thinking_final"
1084
- : "thinking_delta",
1085
- text: merged,
1094
+ kind: done ? "thinking_final" : "thinking_delta",
1095
+ text: step.current,
1086
1096
  });
1097
+ if (done)
1098
+ ctx.thinkingBuf.value = "";
1087
1099
  return out;
1088
1100
  }
1089
1101
  if (type === "assistant") {
@@ -1091,13 +1103,19 @@ export class CursorAgentBackend {
1091
1103
  const thinking = extractThinkingText(ev.message ?? ev);
1092
1104
  if (thinking) {
1093
1105
  ctx.thinkingBuf ??= { value: "" };
1094
- const mergedThinking = mergeThinkingText(ctx.thinkingBuf.value, thinking);
1095
- if (mergedThinking !== ctx.thinkingBuf.value) {
1096
- ctx.thinkingBuf.value = mergedThinking;
1106
+ const step = advanceThinking(ctx.thinkingBuf.value, thinking);
1107
+ if (step.finalize) {
1108
+ out.push({ kind: "thinking_final", text: step.finalize });
1109
+ }
1110
+ if (step.current !== ctx.thinkingBuf.value || step.finalize) {
1111
+ ctx.thinkingBuf.value = step.current;
1112
+ const done = !("timestamp_ms" in ev);
1097
1113
  out.push({
1098
- kind: "timestamp_ms" in ev ? "thinking_delta" : "thinking_final",
1099
- text: mergedThinking,
1114
+ kind: done ? "thinking_final" : "thinking_delta",
1115
+ text: step.current,
1100
1116
  });
1117
+ if (done)
1118
+ ctx.thinkingBuf.value = "";
1101
1119
  }
1102
1120
  }
1103
1121
  }
@@ -1134,6 +1152,7 @@ export class CursorAgentBackend {
1134
1152
  // Nested Task/subagent tools (parentToolCallId) still become first-class
1135
1153
  // chat cards so cloud/local Cursor subagent work is visible.
1136
1154
  if (ev.subtype === "started") {
1155
+ ctx.thinkingBuf = { value: "" };
1137
1156
  out.push({
1138
1157
  kind: "tool_start",
1139
1158
  callId,
@@ -18,6 +18,7 @@ export declare class OpencodeBackend implements WorkerBackend {
18
18
  private usage;
19
19
  private textSent;
20
20
  private thinkingSent;
21
+ private thinkingPartId;
21
22
  private startedTools;
22
23
  private doneTools;
23
24
  private anonSeq;
@@ -33,6 +34,8 @@ export declare class OpencodeBackend implements WorkerBackend {
33
34
  /** OpenCode takes MCP servers through its merged config, not a CLI flag. */
34
35
  buildEnv(opts: BuildArgsOptions): Record<string, string>;
35
36
  private appendText;
37
+ /** One thought per reasoning part. A new part finalizes the previous thought. */
38
+ private emitThinking;
36
39
  private parseTool;
37
40
  parseLine(json: unknown, ctx?: ParseLineContext): NormalizedAgentEvent[];
38
41
  flush(ctx?: ParseLineContext): NormalizedAgentEvent[];
@@ -145,6 +145,7 @@ export class OpencodeBackend {
145
145
  };
146
146
  textSent = new Map();
147
147
  thinkingSent = new Map();
148
+ thinkingPartId = "";
148
149
  startedTools = new Set();
149
150
  doneTools = new Set();
150
151
  anonSeq = 0;
@@ -216,13 +217,32 @@ export class OpencodeBackend {
216
217
  }
217
218
  if (!chunk)
218
219
  return "";
219
- // A new part (e.g. text after a tool step) starts its own paragraph.
220
- if (!already && buffer.value && !/\s$/.test(buffer.value) && !/^\s/.test(chunk)) {
220
+ // Text parts after a tool continue the reply. Thinking parts must not —
221
+ // each reasoning block is its own thought (see emitThinking).
222
+ if (kind === "text" &&
223
+ !already &&
224
+ buffer.value &&
225
+ !/\s$/.test(buffer.value) &&
226
+ !/^\s/.test(chunk)) {
221
227
  buffer.value += "\n\n";
222
228
  }
223
229
  buffer.value += chunk;
224
230
  return buffer.value;
225
231
  }
232
+ /** One thought per reasoning part. A new part finalizes the previous thought. */
233
+ emitThinking(ctx, partId, incoming, full) {
234
+ const out = [];
235
+ const thinking = (ctx.thinkingBuf ??= { value: "" });
236
+ if (this.thinkingPartId && this.thinkingPartId !== partId && thinking.value) {
237
+ out.push({ kind: "thinking_final", text: thinking.value });
238
+ thinking.value = "";
239
+ }
240
+ this.thinkingPartId = partId;
241
+ const text = this.appendText(ctx, partId, incoming, "thinking", full);
242
+ if (text)
243
+ out.push({ kind: "thinking_delta", text });
244
+ return out;
245
+ }
226
246
  parseTool(ev, part, ctx) {
227
247
  const out = [];
228
248
  const rawName = textField(part.tool) || "tool";
@@ -242,6 +262,8 @@ export class OpencodeBackend {
242
262
  const done = status === "completed" || status === "error" || status === "failed";
243
263
  if (!this.startedTools.has(callId)) {
244
264
  this.startedTools.add(callId);
265
+ (ctx.thinkingBuf ??= { value: "" }).value = "";
266
+ this.thinkingPartId = "";
245
267
  out.push({
246
268
  kind: "tool_start",
247
269
  callId,
@@ -348,9 +370,7 @@ export class OpencodeBackend {
348
370
  if (!delta || ctx.toolsOnly)
349
371
  return out;
350
372
  if (partType === "reasoning" || partType === "thinking") {
351
- const full = this.appendText(ctx, partId, delta, "thinking", false);
352
- if (full)
353
- out.push({ kind: "thinking_delta", text: full });
373
+ return out.concat(this.emitThinking(ctx, partId, delta, false));
354
374
  }
355
375
  else {
356
376
  const full = this.appendText(ctx, partId, delta, "text", false);
@@ -367,9 +387,7 @@ export class OpencodeBackend {
367
387
  if (!text)
368
388
  return out;
369
389
  if (type === "reasoning") {
370
- const full = this.appendText(ctx, partId, text, "thinking", true);
371
- if (full)
372
- out.push({ kind: "thinking_delta", text: full });
390
+ return out.concat(this.emitThinking(ctx, partId, text, true));
373
391
  }
374
392
  else {
375
393
  const full = this.appendText(ctx, partId, text, "text", true);
@@ -750,7 +750,7 @@ export interface WorkerToServerEvents {
750
750
  snapshot?: WorkspaceGitSnapshot;
751
751
  error?: string;
752
752
  }) => void;
753
- /** Protocol 12+: commit, push, or checkout finished. */
753
+ /** Protocol 12+: commit, push, checkout, create, or discard finished. */
754
754
  "worker:workspace-git-action-result": (data: {
755
755
  requestId: string;
756
756
  ok?: boolean;
@@ -842,13 +842,16 @@ export interface ServerToWorkerEvents {
842
842
  repoPath: string;
843
843
  cwd?: string;
844
844
  }) => void;
845
- /** Protocol 12+: commit, push, or switch the branch in a local repo. */
845
+ /** Protocol 12+: commit, push, switch, create, or discard in a local repo. */
846
846
  "worker:workspace-git-action": (data: {
847
847
  requestId: string;
848
848
  repoPath: string;
849
- action: "commit" | "push" | "checkout";
849
+ action: "commit" | "push" | "checkout" | "create" | "discard";
850
850
  message?: string;
851
851
  branch?: string;
852
+ path?: string;
853
+ /** GitHub token for push/fetch; worker stores it in the machine credential helper. */
854
+ token?: string;
852
855
  }) => void;
853
856
  /** Protocol 11+: list a folder inside a local repo. */
854
857
  "worker:workspace-dir": (data: {
@@ -24,12 +24,10 @@ export function mergeChatMessage(prev, incoming) {
24
24
  }
25
25
  const kept = prev.thinking?.trim() ? prev.thinking : undefined;
26
26
  const nextThinking = incoming.thinking?.trim() ? incoming.thinking : undefined;
27
- if (kept && (!nextThinking || kept.length > nextThinking.length)) {
28
- next.thinking = kept;
29
- }
30
- else if (nextThinking) {
27
+ if (nextThinking)
31
28
  next.thinking = nextThinking;
32
- }
29
+ else if (kept)
30
+ next.thinking = kept;
33
31
  return next;
34
32
  }
35
33
  /**
@@ -76,11 +76,15 @@ export interface MachineSetupTokenInfo {
76
76
  createdAt: number;
77
77
  createdBy: string;
78
78
  }
79
+ export interface MachineSetupStep {
80
+ title: string;
81
+ command: string;
82
+ }
79
83
  export interface MachineSetupCommands {
80
- /** curl … | sh one-liner (installs the CLI, connects, installs a service). */
84
+ /** curl … | sh one-liner (installs agent CLIs + Eigen, connects, service). */
81
85
  script: string;
82
- /** Manual: install the CLI, connect, start as a service. */
83
- manual: string[];
86
+ /** Manual steps: PATH, agent CLIs, Eigen CLI, connect, service. */
87
+ manual: MachineSetupStep[];
84
88
  /** docker run … */
85
89
  docker: string;
86
90
  }
@@ -17,7 +17,7 @@ const STEER_COMMANDS = [
17
17
  },
18
18
  {
19
19
  name: "status",
20
- description: "Show this agent's backend, model, run state, branch, and PR",
20
+ description: "Show this agent's backend, model, machine, clone path, branch, and PR",
21
21
  handledBy: "steer",
22
22
  },
23
23
  {
@@ -25,6 +25,8 @@ export interface WorkspaceGitSnapshot {
25
25
  export declare function parseGitBranches(raw: string, current: string | null): string[];
26
26
  export declare function assertGitBranchName(raw: string): string;
27
27
  export declare function assertCommitMessage(raw: string): string;
28
+ /** Repo-relative path from `git status --porcelain` (or an empty string to discard everything). */
29
+ export declare function assertGitChangePath(raw: string): string;
28
30
  export interface WorkspaceRepoEntry {
29
31
  name: string;
30
32
  /** Repo-relative path. */
@@ -38,3 +38,16 @@ export function assertCommitMessage(raw) {
38
38
  throw new Error("Commit message is too long");
39
39
  return message;
40
40
  }
41
+ /** Repo-relative path from `git status --porcelain` (or an empty string to discard everything). */
42
+ export function assertGitChangePath(raw) {
43
+ const path = raw.trim().replace(/\\/g, "/").replace(/^"+|"+$/g, "");
44
+ if (!path)
45
+ return "";
46
+ if (path.length > 1_000 ||
47
+ path.startsWith("/") ||
48
+ path.includes("\0") ||
49
+ path.split("/").some((part) => part === "..")) {
50
+ throw new Error("Invalid path");
51
+ }
52
+ return path;
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geteigen/cli",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "CLI worker for Eigen — run local Cursor/ Claude Code/Codex AI agents for multiplayer sessions",
5
5
  "type": "module",
6
6
  "bin": {