@geteigen/cli 3.0.0 → 3.2.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.
- package/dist/cli/src/agent.js +6 -3
- package/dist/cli/src/agentTools.d.ts +1 -5
- package/dist/cli/src/agentTools.js +51 -17
- package/dist/cli/src/cliEnv.d.ts +6 -0
- package/dist/cli/src/cliEnv.js +26 -0
- package/dist/cli/src/gitAuthEnv.d.ts +5 -0
- package/dist/cli/src/gitAuthEnv.js +35 -0
- package/dist/cli/src/proc.d.ts +8 -0
- package/dist/cli/src/proc.js +20 -0
- package/dist/cli/src/service.js +10 -1
- package/dist/cli/src/workRoots.js +66 -21
- package/dist/cli/src/workerCore.js +26 -7
- package/dist/cli/src/workspace.d.ts +2 -0
- package/dist/cli/src/workspace.js +71 -11
- package/dist/shared/automations.d.ts +4 -5
- package/dist/shared/automations.js +12 -5
- package/dist/shared/backends/claudeCode.d.ts +2 -0
- package/dist/shared/backends/claudeCode.js +12 -4
- package/dist/shared/backends/cursor.d.ts +9 -0
- package/dist/shared/backends/cursor.js +31 -12
- package/dist/shared/backends/opencode.d.ts +3 -0
- package/dist/shared/backends/opencode.js +26 -8
- package/dist/shared/events.d.ts +6 -3
- package/dist/shared/events.js +3 -5
- package/dist/shared/machines.d.ts +7 -3
- package/dist/shared/slashCommands.js +1 -1
- package/dist/shared/workspace.d.ts +2 -0
- package/dist/shared/workspace.js +13 -0
- package/package.json +1 -1
package/dist/cli/src/agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { killCli,
|
|
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 = { ...
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
package/dist/cli/src/proc.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/src/proc.js
CHANGED
|
@@ -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;
|
package/dist/cli/src/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,48 @@ 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
|
+
}
|
|
134
|
+
/** Commit as the GitHub user who owns the token — never “Eigen Agent”. */
|
|
135
|
+
async function githubIdentityFromToken(token) {
|
|
136
|
+
if (!token?.trim())
|
|
137
|
+
return null;
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch("https://api.github.com/user", {
|
|
140
|
+
headers: {
|
|
141
|
+
Authorization: `Bearer ${token.trim()}`,
|
|
142
|
+
Accept: "application/vnd.github+json",
|
|
143
|
+
"User-Agent": "eigen-cli",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok)
|
|
147
|
+
return null;
|
|
148
|
+
const user = (await res.json());
|
|
149
|
+
const login = String(user.login || "").trim();
|
|
150
|
+
if (!login)
|
|
151
|
+
return null;
|
|
152
|
+
const email = typeof user.email === "string" && user.email.includes("@")
|
|
153
|
+
? user.email.trim()
|
|
154
|
+
: `${user.id ? `${user.id}+` : ""}${login}@users.noreply.github.com`;
|
|
155
|
+
return { name: String(user.name || "").trim() || login, email };
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function applyGithubCommitIdentity(cwd, token) {
|
|
162
|
+
const id = await githubIdentityFromToken(token);
|
|
163
|
+
if (!id || !existsSync(join(cwd, ".git")))
|
|
164
|
+
return id;
|
|
165
|
+
await git(cwd, ["config", "user.name", id.name]);
|
|
166
|
+
await git(cwd, ["config", "user.email", id.email]);
|
|
167
|
+
return id;
|
|
168
|
+
}
|
|
125
169
|
/** Strip credentials so errors never echo a token. */
|
|
126
170
|
function scrub(message, token) {
|
|
127
171
|
let out = message.replace(/x-access-token:[^@\s]+@/g, "x-access-token:***@");
|
|
@@ -161,9 +205,8 @@ export async function prepareRepo(roots, input) {
|
|
|
161
205
|
await git(dirname(target), ["clone", remote, target], 600_000);
|
|
162
206
|
await git(target, ["checkout", ref]);
|
|
163
207
|
}
|
|
164
|
-
await git(target, ["config", "user.name", "Eigen Agent"]);
|
|
165
|
-
await git(target, ["config", "user.email", "agent@users.noreply.github.com"]);
|
|
166
208
|
}
|
|
209
|
+
await applyGithubCommitIdentity(target, input.token);
|
|
167
210
|
if (input.branch) {
|
|
168
211
|
await git(target, ["checkout", "-B", input.branch, `origin/${ref}`]).catch(() => git(target, ["checkout", "-B", input.branch]));
|
|
169
212
|
}
|
|
@@ -173,8 +216,7 @@ export async function prepareRepo(roots, input) {
|
|
|
173
216
|
throw new Error(scrub(err.stderr || err.message, input.token));
|
|
174
217
|
}
|
|
175
218
|
finally {
|
|
176
|
-
|
|
177
|
-
await forgetRemoteToken(target);
|
|
219
|
+
await persistGithubAuth(target, input.token);
|
|
178
220
|
}
|
|
179
221
|
}
|
|
180
222
|
export async function finalizeGit(roots, input) {
|
|
@@ -186,16 +228,20 @@ export async function finalizeGit(roots, input) {
|
|
|
186
228
|
const status = await git(cwd, ["status", "--porcelain"]);
|
|
187
229
|
let committed = false;
|
|
188
230
|
if (status.stdout.trim()) {
|
|
189
|
-
await
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
231
|
+
const author = await applyGithubCommitIdentity(cwd, input.token);
|
|
232
|
+
const commitArgs = author
|
|
233
|
+
? [
|
|
234
|
+
"-c",
|
|
235
|
+
`user.name=${author.name}`,
|
|
236
|
+
"-c",
|
|
237
|
+
`user.email=${author.email}`,
|
|
238
|
+
"commit",
|
|
239
|
+
"-q",
|
|
240
|
+
"-m",
|
|
241
|
+
input.message || "Changes from Eigen agent",
|
|
242
|
+
]
|
|
243
|
+
: ["commit", "-q", "-m", input.message || "Changes from Eigen agent"];
|
|
244
|
+
await git(cwd, commitArgs);
|
|
199
245
|
committed = true;
|
|
200
246
|
}
|
|
201
247
|
if (input.token) {
|
|
@@ -215,7 +261,6 @@ export async function finalizeGit(roots, input) {
|
|
|
215
261
|
throw new Error(scrub(err.stderr || err.message, input.token));
|
|
216
262
|
}
|
|
217
263
|
finally {
|
|
218
|
-
|
|
219
|
-
await forgetRemoteToken(cwd);
|
|
264
|
+
await persistGithubAuth(cwd, input.token);
|
|
220
265
|
}
|
|
221
266
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
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
|
-
|
|
142
|
+
`user.name=${author.name}`,
|
|
111
143
|
"-c",
|
|
112
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
1078
|
-
if (
|
|
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 =
|
|
1091
|
+
ctx.thinkingBuf.value = step.current;
|
|
1092
|
+
const done = ev.subtype === "completed";
|
|
1081
1093
|
out.push({
|
|
1082
|
-
kind:
|
|
1083
|
-
|
|
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
|
|
1095
|
-
if (
|
|
1096
|
-
|
|
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:
|
|
1099
|
-
text:
|
|
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
|
-
//
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/shared/events.d.ts
CHANGED
|
@@ -750,7 +750,7 @@ export interface WorkerToServerEvents {
|
|
|
750
750
|
snapshot?: WorkspaceGitSnapshot;
|
|
751
751
|
error?: string;
|
|
752
752
|
}) => void;
|
|
753
|
-
/** Protocol 12+: commit, push, or
|
|
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,
|
|
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: {
|
package/dist/shared/events.js
CHANGED
|
@@ -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 (
|
|
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
|
|
84
|
+
/** curl … | sh one-liner (installs agent CLIs + Eigen, connects, service). */
|
|
81
85
|
script: string;
|
|
82
|
-
/** Manual:
|
|
83
|
-
manual:
|
|
86
|
+
/** Manual steps: PATH, agent CLIs, Eigen CLI, connect, service. */
|
|
87
|
+
manual: MachineSetupStep[];
|
|
84
88
|
/** docker run … */
|
|
85
89
|
docker: string;
|
|
86
90
|
}
|
|
@@ -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. */
|
package/dist/shared/workspace.js
CHANGED
|
@@ -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
|
+
}
|