feat: add updates to cli application (#503)

* open swe cli: initial cli updates

* openswe cli: add working authentication

* open swe cli: remove unecessary console log statements

* openswe cli: typescript modifications

* openswe cli: updates to formats

* openswe cli: updates to formatting

* openswe cli: ran prettier

* openswe cli: update urls

* openswe cli: update eslint

* openswe cli: add env example

* openswe cli: add updates

* openswe cli: formatting

* openswe cli: add app installation

* openswe cli: clean up lint and formatting

* temp logs

* l

* working human feedback prompting

* stop the re-renders

* NOT WORKING -- KICKOFF PROGRAMMER

* approve programmer

* detected programmer working

* some sembleance of logging

* some changes

* working logs

* concatenated tool results

* ui improvements

* update ui

* formatting

* simple interrupt working

* working multiple sessions + padding

* working dissapearance of the approve/deny

* TEST PUSH

* cr

* generally working tool calls

* access thread ip properly

* fix naming of env example

* add readme

* formatting

* linting + formatting

* dist updates

* updates to yarn

* no tests for cli

* updates to readme

* format

* update readme

* remove auth print out

* add loading indicators

* formatting changes

* remove shared workspace

* add lock

* add dependency

* update yarn lock

* add back the installation token

* replace with variable constants

* Update apps/cli/src/auth-server.ts

Co-authored-by: Brace Sproul <braceasproul@gmail.com>

* openswe cli: updates

* openswe cli: cleanup lint

* openswe cli: update stream mode

* openswe cli: update stream mode

* openswe cli: streaming service

* openswe cli: create selector for approval

* openswe cli: remove streaming service

* openswe cli: update types to langchain

* openswe cli: update all streamModes

* openswe cli: update formatting

* openswe cli: update stream mode

* openswe cli: notify of in development

* openswe cli: change to istoolmessage

---------

Co-authored-by: bracesproul <braceasproul@gmail.com>
This commit is contained in:
Palash Shah 2025-07-29 17:37:13 -04:00 • committed by GitHub
parent 68216a88e6
commit 01a514b1fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1441 additions and 140 deletions

View file

@ -1,12 +1,14 @@
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
GITHUB_CALLBACK_URL=""
GITHUB_LOGIN_URL=""
GITHUB_ACCESS_TOKEN_LOGIN_LINK=
GITHUB_APP_CLIENT_ID=""
GITHUB_APP_CLIENT_SECRET=""
GITHUB_APP_NAME="open-swe-dev"
GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
...add your private key here...
-----END RSA PRIVATE KEY-----
"
GITHUB_CALLBACK_URL=""
GITHUB_LOGIN_URL=""
GITHUB_ACCESS_TOKEN_LOGIN_LINK=
SECRETS_ENCRYPTION_KEY=""

30
apps/cli/README.md Normal file
View file

@ -0,0 +1,30 @@
# Open SWE CLI
> **⚠️ Under Development**
> This CLI is currently under active development and may contain bugs or incomplete features.
A command-line interface for Open SWE that provides a terminal-based chat experience to interact with the autonomous coding agent. Built with React and Ink, it offers real-time streaming of agent logs, repository selection, and GitHub integration directly from your terminal.
## Documentation
## Development
1. Copy the environment file: `cp .env.example .env` and fill in the required values
2. Install dependencies: `yarn install`
3. Build the CLI: `yarn build`
4. Run the CLI: `yarn cli`
## Usage
Run the CLI and follow the interactive prompts:
```bash
yarn cli
```
The CLI will guide you through:
1. GitHub authentication (if not already logged in)
2. Repository selection
3. GitHub App installation
4. Interactive chat with the Open SWE agent

View file

@ -12,26 +12,31 @@
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "echo \"Error: no test specified\" && exit 0",
"dev": "tsx src/index.tsx"
"test": "echo \"No tests yet\" && exit 0",
"dev": "tsx src/index.tsx",
"cli": "npx tsc && node dist/index.js"
},
"dependencies": {
"@langchain/langgraph-sdk": "^0.0.95",
"@open-swe/shared": "*",
"dotenv": "^16.6.1",
"express": "^4.18.2",
"ink": "^6.0.1",
"keytar": "^7.9.0",
"node-fetch": "^2.6.7",
"open": "^9.1.0",
"react": "^19.1.0"
"react": "^19.1.0",
"uuid": "^10.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.19.0",
"@tsconfig/recommended": "^1.0.8",
"@types/express": "^4.17.21",
"@types/node": "^22.13.5",
"@types/node": "^24.1.0",
"@types/node-fetch": "^2.6.4",
"@types/react": "^19.1.8",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.19.0",

View file

@ -5,12 +5,14 @@ interface TerminalInterfaceProps {
message: string | null;
setMessage: () => void;
CustomInput: React.FC<{ onSubmit: () => void }>;
repoName: string;
}
const TerminalInterface: React.FC<TerminalInterfaceProps> = ({
message,
setMessage,
CustomInput,
repoName,
}) => {
return (
<Box flexDirection="column" padding={1}>
@ -28,13 +30,18 @@ const TerminalInterface: React.FC<TerminalInterfaceProps> = ({
marginTop={0}
marginBottom={0}
>
<CustomInput onSubmit={setMessage} />
<CustomInput onSubmit={() => setMessage()} />
</Box>
{message && (
<Box marginTop={1}>
<Text color="green">You typed: {message}</Text>
</Box>
)}
{repoName && (
<Box marginTop={0} marginBottom={0}>
<Text color="gray">Repository: {repoName}</Text>
</Box>
)}
</Box>
);
};

View file

@ -4,11 +4,14 @@ import type { Request, Response } from "express";
import fs from "fs";
import os from "os";
import path from "path";
import jwt from "jsonwebtoken";
const CLIENT_ID = process.env.GITHUB_CLIENT_ID || "";
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || "";
const PORT = 3000;
const CALLBACK_URL = "http://localhost:3000/api/auth/github/callback";
const CLIENT_ID = process.env.GITHUB_APP_CLIENT_ID || "";
const CLIENT_SECRET = process.env.GITHUB_APP_CLIENT_SECRET || "";
const PORT = process.env.PORT || 3000;
const CALLBACK_URL =
process.env.GITHUB_CALLBACK_URL ||
`http://localhost:${PORT}/api/auth/github/callback`;
const TOKEN_PATH = path.join(
os.homedir(),
@ -16,38 +19,25 @@ const TOKEN_PATH = path.join(
"github_token.json",
);
const APP_ID = process.env.GITHUB_APP_ID || "";
const GITHUB_PRIVATE_KEY = process.env.GITHUB_APP_PRIVATE_KEY || "";
let accessToken: string | null = null;
let serverStarted = false;
interface GitHubTokenResponse {
access_token?: string;
token_type?: string;
scope?: string;
error?: string;
error_description?: string;
error_uri?: string;
}
function saveToken(tokenData: GitHubTokenResponse) {
function saveToken(tokenData: any) {
const dir = path.dirname(TOKEN_PATH);
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokenData, null, 2), {
mode: 0o600,
});
} catch (err) {
console.error("Failed to save token:", err);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokenData, null, 2), {
mode: 0o600,
});
}
function loadToken() {
try {
if (fs.existsSync(TOKEN_PATH)) {
return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8"));
}
} catch (err) {
console.error("Failed to load token:", err);
if (fs.existsSync(TOKEN_PATH)) {
return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8"));
}
return null;
}
@ -57,52 +47,73 @@ const app = express();
// 1. Start OAuth flow
app.get("/api/auth/github/login", (_req: Request, res: Response) => {
const state = Math.random().toString(36).substring(2);
const baseGithubAuthUrl = "https://github.com/login/oauth/authorize";
const url = new URL(baseGithubAuthUrl);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", CALLBACK_URL);
url.searchParams.set("state", state);
return res.redirect(url.toString());
const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(CALLBACK_URL)}&state=${state}`;
res.redirect(githubAuthUrl);
});
// 2. Handle OAuth callback
app.get("/api/auth/github/callback", async (req: Request, res: Response) => {
const code = req.query.code as string;
// Optionally validate state here
if (!code) {
return res.status(400).send("Missing code parameter");
const installationId = req.query.installation_id as string | undefined;
let tokenData: any = loadToken() || {};
// If OAuth code is present, exchange for access token
if (code) {
// Exchange code for access token
const tokenRes = await fetch(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: CALLBACK_URL,
}),
},
);
const fetchedTokenData = await tokenRes.json();
if (fetchedTokenData.error) {
return res
.status(400)
.send("Error exchanging code for token: " + fetchedTokenData.error);
}
accessToken = fetchedTokenData.access_token;
tokenData.access_token = accessToken;
// Store the token in a config file
try {
saveToken(tokenData);
} catch (err) {
console.error("Failed to store token in config file:", err);
}
}
const GITHUB_ACCESS_TOKEN_LOGIN_LINK =
process.env.GITHUB_ACCESS_TOKEN_LOGIN_LINK ||
"https://github.com/login/oauth/access_token";
// Exchange code for access token
const tokenRes = await fetch(GITHUB_ACCESS_TOKEN_LOGIN_LINK, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: CALLBACK_URL,
}),
});
const tokenData = await tokenRes.json();
if (tokenData.error) {
return res
.status(400)
.send("Error exchanging code for token: " + tokenData.error);
// If installation_id is present, save it
if (installationId) {
tokenData.installation_id = installationId;
try {
saveToken(tokenData);
} catch (err) {
console.error("Failed to store installation_id in config file:", err);
}
// Immediately fetch the installation access token to test the fetch and print info
await getInstallationAccessToken();
}
accessToken = tokenData.access_token;
// Store the token in a config file
try {
saveToken(tokenData);
} catch (err) {
console.error("Failed to store token in config file:", err);
if (code || installationId) {
res.send("Authentication successful! You can close this window.");
} else {
res.status(400).send("Missing code or installation_id parameter");
}
return res.send("Authentication successful! You can close this window.");
});
export function startAuthServer() {
if (serverStarted) return;
serverStarted = true;
app.listen(PORT, () => {});
}
@ -112,3 +123,52 @@ export function getAccessToken() {
const tokenData = loadToken();
return tokenData ? tokenData.access_token : null;
}
export function getInstallationId() {
const tokenData = loadToken();
return tokenData ? tokenData.installation_id : null;
}
export async function getInstallationAccessToken(): Promise<string | null> {
// Load installation_id from config file
const tokenData = loadToken();
const installationId = tokenData?.installation_id;
if (!installationId) {
console.error("No installation_id found in config file.");
return null;
}
if (!APP_ID || !GITHUB_PRIVATE_KEY) {
console.error("GITHUB_APP_ID or GITHUB_APP_PRIVATE_KEY not set.");
return null;
}
// Use the key contents from the env var, replacing escaped newlines
const privateKey = GITHUB_PRIVATE_KEY.replace(/\\n/g, "\n");
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now - 60,
exp: now + 10 * 60,
iss: APP_ID,
};
const jwtToken = jwt.sign(payload, privateKey, { algorithm: "RS256" });
const res = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: {
Authorization: `Bearer ${jwtToken}`,
Accept: "application/vnd.github+json",
"User-Agent": "open-swe-cli",
},
},
);
if (!res.ok) {
console.error(
"Failed to fetch installation access token:",
await res.text(),
);
return null;
}
const data = await res.json();
return data.token;
}

View file

@ -1,22 +1,68 @@
#!/usr/bin/env node
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { render, Box, Text, useInput } from "ink";
import { startAuthServer, getAccessToken } from "./auth-server.js";
import {
startAuthServer,
getAccessToken,
getInstallationId,
} from "./auth-server.js";
import open from "open";
import TerminalInterface from "./TerminalInterface.js";
import { v4 as uuidv4 } from "uuid";
import {
MANAGER_GRAPH_ID,
OPEN_SWE_STREAM_MODE,
} from "@open-swe/shared/constants";
import { Client } from "@langchain/langgraph-sdk";
import { submitFeedback } from "./utils.js";
import { StreamingService } from "./streaming.js";
type StreamMode = "values" | "updates" | "messages";
const GITHUB_LOGIN_URL =
process.env.GITHUB_LOGIN_URL || "http://localhost:3000/api/auth/github/login";
const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
startAuthServer();
const LoadingSpinner: React.FC<{ text: string }> = ({ text }) => {
const [dots, setDots] = useState("");
useEffect(() => {
const interval = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? "" : prev + "."));
}, 500);
return () => clearInterval(interval);
}, []);
return (
<Box justifyContent="center" paddingY={2}>
<Text>
{text}
{dots}
</Text>
</Box>
);
};
// eslint-disable-next-line no-unused-vars
const CustomInput: React.FC<{ onSubmit: (value: string) => void }> = ({
onSubmit,
}) => {
const [input, setInput] = useState("");
const [isSubmitted, setIsSubmitted] = useState(false);
useInput((inputChar, key) => {
useInput((inputChar: string, key: { [key: string]: any }) => {
if (isSubmitted) return;
if (key.return) {
setIsSubmitted(true);
onSubmit();
if (input.trim()) {
// Only submit if there's actual content
setIsSubmitted(true);
onSubmit(input);
// Reset for next input
setTimeout(() => {
setInput("");
setIsSubmitted(false);
}, 100);
}
} else if (key.backspace || key.delete) {
setInput((prev) => prev.slice(0, -1));
} else if (inputChar) {
@ -24,11 +70,95 @@ const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
}
});
if (isSubmitted) return null;
return (
<Box>
<Text color="cyan">&gt; {input}</Text>
<Text>&gt; {input}</Text>
</Box>
);
};
async function fetchUserRepos(token: string) {
const allRepos = [];
let page = 1;
const perPage = 100;
while (true) {
const res = await fetch(
`https://api.github.com/user/repos?per_page=${perPage}&page=${page}`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "open-swe-cli",
},
},
);
if (!res.ok) throw new Error("Failed to fetch repos");
const repos = await res.json();
allRepos.push(...repos);
if (repos.length < perPage) break;
page++;
}
return allRepos;
}
const RepoSearchSelect: React.FC<{
repos: any[];
// eslint-disable-next-line no-unused-vars
onSelect: (repo: any) => void;
}> = ({ repos, onSelect }) => {
const [search, setSearch] = useState("");
const [highlighted, setHighlighted] = useState(0);
const [isMessage, setIsMessage] = useState(false);
const filtered = repos.filter((repo) =>
repo.full_name.toLowerCase().includes(search.toLowerCase()),
);
const shown = filtered.slice(0, 10);
useInput((input: string, key: { [key: string]: any }) => {
if (isMessage) return;
if (key.return) {
if (shown.length > 0) {
setIsMessage(true);
onSelect(shown[highlighted]);
}
} else if (key.upArrow) {
setHighlighted((h) => (h - 1 + shown.length) % shown.length);
} else if (key.downArrow) {
setHighlighted((h) => (h + 1) % shown.length);
} else if (key.backspace || key.delete) {
setSearch((prev) => prev.slice(0, -1));
setHighlighted(0);
} else if (input && !key.ctrl && !key.meta) {
setSearch((prev) => prev + input);
setHighlighted(0);
}
});
if (isMessage) return null;
return (
<Box flexDirection="column">
<Box>
<Text>Search repositories: {search}</Text>
</Box>
{shown.length === 0 ? (
<Box>
<Text dimColor>No matches found.</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
{shown.map((_, idx) => (
<Text key={shown[idx].id} dimColor={idx !== highlighted}>
{idx === highlighted ? "> " : " "}
{shown[idx].full_name}
</Text>
))}
</Box>
)}
<Box marginTop={1}>
<Text dimColor>Use ↑/↓ to navigate, Enter to select</Text>
</Box>
</Box>
);
};
@ -36,10 +166,72 @@ const CustomInput: React.FC<{ onSubmit: () => void }> = ({ onSubmit }) => {
const App: React.FC = () => {
const [authPrompt, setAuthPrompt] = useState<null | boolean>(null);
const [authInput, setAuthInput] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [exit, setExit] = useState(false);
const [authStarted, setAuthStarted] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [repos, setRepos] = useState<any[]>([]);
const [selectedRepo, setSelectedRepo] = useState<any | null>(null);
const [selectingRepo, setSelectingRepo] = useState(false);
const [waitingForInstall, setWaitingForInstall] = useState(false);
const [installChecked, setInstallChecked] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [appSlug, setAppSlug] = useState(process.env.GITHUB_APP_NAME || "");
const INSTALLATION_CALLBACK_URL = process.env.GITHUB_CALLBACK_URL || "";
const [pollingForToken, setPollingForToken] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [plannerFeedback, setPlannerFeedback] = useState<string | null>(null);
const [streamingPhase, setStreamingPhase] = useState<
"streaming" | "awaitingFeedback" | "done"
>("streaming");
const [threadId, setThreadId] = useState<string | null>(null);
const [plannerThreadId, setPlannerThreadId] = useState<string | null>(null);
const [hasStartedChat, setHasStartedChat] = useState(false);
const [client, setClient] = useState<Client | null>(null);
const [loadingRepos, setLoadingRepos] = useState(false);
const [loadingLogs, setLoadingLogs] = useState(false);
const sendInterruptMessage = useCallback(
async (message: string) => {
if (!client || !threadId || !selectedRepo) {
return;
}
setLogs((prev) => [...prev, `📤 Interrupt Response: "${message}"`]);
try {
const [owner, repoName] = selectedRepo.full_name.split("/");
const interruptInput = {
messages: [
{
id: uuidv4(),
type: "human",
content: [{ type: "text", text: message }],
},
],
targetRepository: {
owner,
repo: repoName,
branch: selectedRepo.default_branch || "main",
},
};
await client.runs.create(threadId, MANAGER_GRAPH_ID, {
input: interruptInput,
config: { recursion_limit: 400 },
ifNotExists: "create",
streamResumable: true,
multitaskStrategy: "enqueue",
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
});
// Just submit the interrupt - existing planner session will pick it up automatically
setLogs((prev) => [...prev, `✅ Interrupt sent to existing session`]);
} catch (err: any) {
setLogs((prev) => [...prev, `Error sending interrupt: ${err.message}`]);
}
},
[client, threadId, selectedRepo, setLogs],
);
// On mount, check for existing token
useEffect(() => {
@ -49,22 +241,56 @@ const App: React.FC = () => {
}
}, []);
// Poll for token after auth flow starts
// After login, fetch and store user repos
useEffect(() => {
if (authStarted && !isLoggedIn) {
const interval = setInterval(() => {
const token = getAccessToken();
if (token) {
setIsLoggedIn(true);
clearInterval(interval);
}
}, 1000);
return () => clearInterval(interval);
if (isLoggedIn && repos.length === 0 && !loadingRepos) {
const token = getAccessToken();
if (token) {
setLoadingRepos(true);
fetchUserRepos(token)
.then((repos) => {
setRepos(repos);
setSelectingRepo(true);
setLoadingRepos(false);
})
.catch((err) => {
console.error("Failed to fetch repos:", err);
setLoadingRepos(false);
});
}
}
}, [authStarted, isLoggedIn]);
}, [isLoggedIn, repos.length, loadingRepos]);
// Poll for installation_id after opening install page
useEffect(() => {
let interval: ReturnType<typeof setInterval>;
if (waitingForInstall) {
interval = setInterval(() => {
// Check if installation_id is present in config file
const installationId = getInstallationId();
if (installationId) {
setInstallChecked(true);
setWaitingForInstall(false);
}
}, 1500);
}
return () => clearInterval(interval);
}, [waitingForInstall]);
// Listen for Cmd+C/Ctrl+C to re-select repo
useInput((input: string, key: { [key: string]: any }) => {
if (installChecked && !waitingForInstall && key.return) {
setInstallChecked(false);
setSelectingRepo(false);
}
if (selectedRepo && (key.ctrl || key.meta) && input.toLowerCase() === "c") {
setSelectingRepo(true);
setSelectedRepo(null);
}
});
// Handle yes/no input for auth prompt
useInput((input, key) => {
useInput((input: string, key: { [key: string]: any }) => {
if (authPrompt === null && !isLoggedIn) {
if (key.return) {
if (authInput.toLowerCase() === "y") {
@ -76,7 +302,6 @@ const App: React.FC = () => {
} else if (key.backspace || key.delete) {
setAuthInput((prev) => prev.slice(0, -1));
} else if (input && authInput.length < 1) {
// Only allow a single character (y/n)
setAuthInput(input);
}
}
@ -95,38 +320,312 @@ const App: React.FC = () => {
setAuthStarted(true);
startAuthServer();
open(GITHUB_LOGIN_URL);
setPollingForToken(true);
}
}, [authPrompt, authStarted]);
if (isLoggedIn) {
// Poll for token after auth flow starts
useEffect(() => {
if (pollingForToken && !isLoggedIn) {
const interval = setInterval(() => {
const token = getAccessToken();
if (token) {
setIsLoggedIn(true);
setPollingForToken(false);
}
}, 1000);
return () => clearInterval(interval);
}
}, [pollingForToken, isLoggedIn]);
// Custom input for planner feedback (must be inside App)
const PlanFeedbackSelect: React.FC = () => {
const [highlighted, setHighlighted] = useState(0);
const [isMessage, setIsMessage] = useState(false);
const options = [
{ value: "approve", label: "Approve" },
{ value: "deny", label: "Deny" },
];
useInput((input: string, key: { [key: string]: any }) => {
if (streamingPhase !== "awaitingFeedback") return;
if (isMessage) return;
if (key.return) {
setIsMessage(true);
setPlannerFeedback(options[highlighted].value);
} else if (key.leftArrow) {
setHighlighted((h) => (h - 1 + options.length) % options.length);
} else if (key.rightArrow) {
setHighlighted((h) => (h + 1) % options.length);
}
});
if (streamingPhase !== "awaitingFeedback") return null;
return (
<Box flexDirection="column" height={"100%"}>
<TerminalInterface
message={message}
setMessage={() => setMessage(null)}
CustomInput={CustomInput}
/>
<Box flexGrow={1} />
<Box width="100%" justifyContent="flex-end">
<Text color="gray" dimColor>
logged in
<Box flexDirection="row" alignItems="center" gap={2}>
<Text>Plan feedback:</Text>
{options.map((option, idx) => (
<Text
key={option.value}
dimColor={idx !== highlighted}
bold={idx === highlighted}
>
{idx === highlighted ? "[" : " "}
{option.label}
{idx === highlighted ? "]" : " "}
</Text>
</Box>
))}
<Text dimColor>Use ←/→ to navigate, Enter to select</Text>
</Box>
);
};
// Add this where we handle planner feedback
useEffect(() => {
if (
streamingPhase === "awaitingFeedback" &&
plannerFeedback &&
plannerThreadId
) {
// Immediately switch to streaming mode to hide the feedback prompt
setStreamingPhase("streaming");
(async () => {
await submitFeedback({
plannerFeedback,
plannerThreadId,
selectedRepo,
setLogs,
setPlannerFeedback: () => setPlannerFeedback(null),
});
})();
}
}, [streamingPhase, plannerFeedback, selectedRepo, plannerThreadId]);
// Loading repos after login
if (isLoggedIn && loadingRepos) {
return (
<Box flexDirection="column" padding={1}>
<LoadingSpinner text="Loading your repositories" />
</Box>
);
}
if (authPrompt === null) {
// Repo selection UI
if (isLoggedIn && repos.length > 0 && (selectingRepo || !selectedRepo)) {
return (
<Box flexDirection="column" padding={1}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color="magenta">
LangChain Open SWE CLI
</Text>
<Text bold>LangChain Open SWE CLI</Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text>Select a repository to work with (type to search):</Text>
</Box>
<Box
borderStyle="round"
borderColor="gray"
borderColor="white"
paddingX={2}
paddingY={1}
marginTop={1}
marginBottom={1}
>
<RepoSearchSelect
repos={repos}
onSelect={async (repo) => {
let slug = appSlug;
const installationId = getInstallationId();
setSelectedRepo(repo);
setSelectingRepo(false);
if (installationId) {
setInstallChecked(true);
setWaitingForInstall(false);
setInstallError(null);
return;
}
if (!slug) {
console.log(
"Please enter your GitHub App slug (as in https://github.com/apps/<slug>):",
);
process.stdin.resume();
process.stdin.setEncoding("utf8");
slug = await new Promise((resolve) => {
process.stdin.once("data", (data) =>
resolve(String(data).trim()),
);
});
setAppSlug(slug);
}
const installUrl = `https://github.com/apps/${slug}/installations/new?redirect_uri=${encodeURIComponent(INSTALLATION_CALLBACK_URL)}`;
console.log(
"Opening GitHub App installation page in your browser...",
);
await open(installUrl);
setWaitingForInstall(true);
setInstallChecked(false);
setInstallError(null);
}}
/>
</Box>
{waitingForInstall && (
<Box flexDirection="column" marginTop={1}>
<Text>Waiting for GitHub App installation to complete...</Text>
<Text dimColor>
After installing the app, return here to continue.
</Text>
</Box>
)}
{installChecked && !waitingForInstall && (
<Box flexDirection="column" marginTop={1}>
<Text>GitHub App installation detected! You can now proceed.</Text>
<Text dimColor>Press Enter to continue.</Text>
</Box>
)}
{installError && (
<Box marginTop={1}>
<Text>{installError}</Text>
</Box>
)}
</Box>
);
}
// Main UI: logs area + input prompt
if (isLoggedIn && selectedRepo) {
// Calculate available space for logs based on whether welcome message is shown
const headerHeight = 0; // Welcome message is now above input bar, not at top
const inputHeight = 4; // Fixed input area height (increased due to padding)
const welcomeHeight = hasStartedChat ? 0 : 8; // Welcome message height when shown
const paddingHeight = 3; // Extra padding to prevent overlap
const availableLogHeight = Math.max(
5,
process.stdout.rows -
headerHeight -
inputHeight -
welcomeHeight -
paddingHeight,
);
// Always show the most recent logs (auto-scroll to bottom)
const visibleLogs =
logs.length > availableLogHeight ? logs.slice(-availableLogHeight) : logs;
return (
<Box flexDirection="column" height={process.stdout.rows}>
{/* Auto-scrolling logs area - strict boundary container */}
<Box
height={availableLogHeight}
flexDirection="column"
paddingX={1}
paddingBottom={1}
overflowY="hidden"
flexShrink={0}
justifyContent="flex-end"
>
<Box flexDirection="column">
{loadingLogs && logs.length === 0 ? (
<LoadingSpinner text="Starting agent" />
) : (
visibleLogs.map((log, index) => (
<Box key={`${logs.length}-${index}`}>
<Text
dimColor={!log.startsWith("[AI]")}
bold={log.startsWith("[AI]")}
>
{log}
</Text>
</Box>
))
)}
</Box>
</Box>
{/* Welcome message right above input bar */}
{!hasStartedChat ? (
<Box flexDirection="column" paddingX={1}>
<Box>
<Text>
{`
## ### ## ## ###### ###### ## ## ### #### ## ##
## ## ## ### ## ## ## ## ## ## ## ## ## ## ### ##
## ## ## #### ## ## ## ## ## ## ## ## #### ##
## ## ## ## ## ## ## #### ## ######### ## ## ## ## ## ##
## ######### ## #### ## ## ## ## ## ######### ## ## ####
## ## ## ## ### ## ## ## ## ## ## ## ## ## ## ###
######## ## ## ## ## ###### ###### ## ## ## ## #### ## ##
`}
</Text>
</Box>
<Box marginTop={2} marginBottom={1}>
<Text dimColor>
Describe your coding problem. It'll run in the sandbox and a PR
will be created.
</Text>
</Box>
</Box>
) : (
<Box height={8} />
)}
{/* Fixed input area at bottom */}
<Box
flexDirection="column"
paddingX={2}
borderStyle="single"
borderTop
height={3}
flexShrink={0}
justifyContent="center"
>
<Box>
{!hasStartedChat ? (
<CustomInput
onSubmit={(value) => {
setHasStartedChat(true);
setPlannerFeedback(null);
const streamingService = new StreamingService({
setLogs,
setPlannerThreadId,
setStreamingPhase,
setLoadingLogs,
setClient,
setThreadId,
});
streamingService.startNewSession(value, selectedRepo);
}}
/>
) : (
<CustomInput
onSubmit={(value) => {
sendInterruptMessage(value);
}}
/>
)}
</Box>
</Box>
{/* Plan feedback below the input bar */}
{streamingPhase === "awaitingFeedback" && (
<Box flexDirection="column" paddingX={2} marginTop={1}>
<PlanFeedbackSelect />
</Box>
)}
</Box>
);
}
// Auth prompt UI
if (!isLoggedIn && authPrompt === null) {
return (
<Box flexDirection="column" padding={1}>
<Box
borderStyle="round"
borderColor="white"
paddingX={2}
paddingY={1}
marginTop={1}
@ -141,21 +640,8 @@ const App: React.FC = () => {
);
}
return (
<Box flexDirection="column" padding={1}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color="magenta">
LangChain Open SWE CLI
</Text>
</Box>
{!message && <CustomInput onSubmit={() => setMessage(null)} />}
{message && (
<Box marginTop={1}>
<Text color="green">You typed: {message}</Text>
</Box>
)}
</Box>
);
// Fallback
return <Box flexDirection="column" padding={1}></Box>;
};
render(<App />);

294
apps/cli/src/logger.ts Normal file
View file

@ -0,0 +1,294 @@
import {
coerceMessageLikeToMessage,
ToolMessage,
isAIMessage,
isHumanMessage,
isToolMessage,
} from "@langchain/core/messages";
import { getMessageContentString } from "@open-swe/shared/messages";
import { createWriteTechnicalNotesToolFields } from "@open-swe/shared/open-swe/tools";
interface LogChunk {
event: string;
data: any;
ops?: Array<{ value: string }>;
}
/**
* Format a tool result based on its type and content
*/
function formatToolResult(message: ToolMessage): string {
const content = getMessageContentString(message.content);
if (!content) return "";
// For successful tool executions, format nicely
const toolName = message.name || "tool";
switch (toolName.toLowerCase()) {
case "shell":
case "grep_search":
case "search":
return content;
case "apply_patch":
return content.includes("Error")
? `Error: ${content}`
: "Patch applied successfully";
case "install_dependencies":
return content.includes("Error")
? `Error: ${content}`
: "Dependencies installed successfully";
default:
if (content.length > 200) {
return content.slice(0, 200) + "...";
}
return content;
}
}
export function formatDisplayLog(chunk: LogChunk | string): string[] {
if (typeof chunk === "string") {
if (chunk.startsWith("Human feedback:")) {
return [
`[HUMAN FEEDBACK RECEIVED] ${chunk.replace("Human feedback:", "").trim()}`,
];
}
if (chunk.startsWith("Interrupt:")) {
const message = chunk.replace("Interrupt:", "").trim();
return [
"═══════════════════════════════════════",
`📤 INTERRUPT: "${message}"`,
"═══════════════════════════════════════",
];
}
// Filter out raw file content and object references
if (
chunk === "[object Object]" ||
chunk.includes("total 4") ||
chunk.includes("drwxr-xr-x") ||
chunk.includes("Exit code 1") ||
chunk.startsWith("#") ||
chunk.startsWith("-") ||
chunk.startsWith("./")
) {
return [];
}
// Single line system messages
const cleanChunk = chunk.replace(/\s+/g, " ").trim();
const maxLength = 150;
const truncated =
cleanChunk.length > maxLength
? cleanChunk.slice(0, maxLength) + "... [trunc]"
: cleanChunk;
return [`[SYSTEM] ${truncated}`];
}
const data = chunk.data;
const logs: string[] = [];
// Handle session events
if (data.plannerSession) {
logs.push("[PLANNER SESSION STARTED]");
}
if (data.programmerSession) {
logs.push("[PROGRAMMER SESSION STARTED]");
}
// Handle messages
const nestedDataObj = Object.values(data)[0] as unknown as Record<
string,
any
>;
if (
nestedDataObj &&
typeof nestedDataObj === "object" &&
"messages" in nestedDataObj
) {
const messages = Array.isArray(nestedDataObj.messages)
? nestedDataObj.messages
: [nestedDataObj.messages];
for (const msg of messages) {
try {
const message = coerceMessageLikeToMessage(msg);
// Handle tool messages
if (isToolMessage(message)) {
const toolName = message.name || "tool";
const result = formatToolResult(message);
if (result) {
// Concatenate long tool results to a single line (truncate if too long)
const maxLength = 500;
let formattedResult = result.replace(/\s+/g, " ");
if (formattedResult.length > maxLength) {
formattedResult =
formattedResult.slice(0, maxLength) + "... [trunc]";
}
logs.push(`[TOOL RESULT] ${toolName}: ${formattedResult}`);
}
continue;
}
// Handle AI messages
if (isAIMessage(message)) {
// Handle reasoning if present
if (message.additional_kwargs?.reasoning) {
const reasoning = String(message.additional_kwargs.reasoning)
.replace(/\s+/g, " ")
.trim();
const maxLength = 150;
const truncated =
reasoning.length > maxLength
? reasoning.slice(0, maxLength) + "... [trunc]"
: reasoning;
logs.push(`[REASONING] ${truncated}`);
}
// Handle tool calls
if (message.tool_calls && message.tool_calls.length > 0) {
const technicalNotesToolName =
createWriteTechnicalNotesToolFields().name;
message.tool_calls.forEach((tool) => {
let argsString = "";
if (typeof tool.args === "string") {
argsString = tool.args;
} else if (tool.args !== undefined) {
try {
argsString = JSON.stringify(tool.args, null, 2);
} catch {
argsString = String(tool.args);
}
}
// Truncate the string if too long
const maxLength = 150;
const truncatedArgs =
argsString.length > maxLength
? argsString.slice(0, maxLength) + "... [trunc]"
: argsString;
const toolName = tool.name || "unknown";
logs.push(`[TOOL CALL] ${toolName}: ${truncatedArgs}`);
// Handle technical notes from tool call
if (
tool.name === technicalNotesToolName &&
tool.args &&
typeof tool.args === "object" &&
"notes" in tool.args
) {
const notes = (tool.args as any).notes;
if (Array.isArray(notes)) {
logs.push(
"[TECHNICAL NOTES]",
...notes.map((note: string) => ` • ${note}`),
);
}
}
});
}
// Handle regular AI messages
const text = getMessageContentString(message.content);
if (text) {
// Always single line, remove newlines and truncate
const cleanText = text.replace(/\s+/g, " ").trim();
const maxLength = 200;
const truncated =
cleanText.length > maxLength
? cleanText.slice(0, maxLength) + "... [trunc]"
: cleanText;
logs.push(`[AI] ${truncated}`);
}
}
// Handle human messages
if (isHumanMessage(message)) {
const text = getMessageContentString(message.content);
if (text) {
// Single line human messages
const cleanText = text.replace(/\s+/g, " ").trim();
const maxLength = 150;
const truncated =
cleanText.length > maxLength
? cleanText.slice(0, maxLength) + "... [trunc]"
: cleanText;
logs.push(`[HUMAN] ${truncated}`);
}
}
} catch (error: any) {
console.error("Error formatting log:", error.message);
// Fallback to original message if conversion fails
if (msg.type === "tool") {
const toolName = msg.name || "tool";
const content = getMessageContentString(msg.content);
if (content) {
logs.push(`[TOOL RESULT] ${toolName}: ${content}`);
}
} else if (msg.type === "ai") {
const text = getMessageContentString(msg.content);
if (text) {
const cleanText = text.replace(/\s+/g, " ").trim();
const maxLength = 200;
const truncated =
cleanText.length > maxLength
? cleanText.slice(0, maxLength) + "... [trunc]"
: cleanText;
logs.push(`[AI] ${truncated}`);
}
} else if (msg.type === "human") {
const text = getMessageContentString(msg.content);
if (text) {
const cleanText = text.replace(/\s+/g, " ").trim();
const maxLength = 150;
const truncated =
cleanText.length > maxLength
? cleanText.slice(0, maxLength) + "... [trunc]"
: cleanText;
logs.push(`[HUMAN] ${truncated}`);
}
}
}
}
}
// Handle feedback messages
if (data.command?.resume?.[0]?.type) {
const type = data.command.resume[0].type;
logs.push(`[HUMAN FEEDBACK RECEIVED] ${type}`);
}
// Handle interrupts and plans
if (data.__interrupt__) {
const interrupt = data.__interrupt__[0]?.value;
if (interrupt?.action_request?.args?.plan) {
const plan = interrupt.action_request.args.plan;
const steps = plan
.split(":::")
.map((s: string) => s.trim())
.filter(Boolean);
// Add clear visual separation and format nicely
logs.push(
" ", // Blank line for separation
"🎯 PROPOSED PLAN",
...steps.map((step: string, idx: number) => ` ${idx + 1}. ${step}`),
" ", // Blank line after
);
} else {
logs.push(
" ", // Blank line for separation
"⏳ INTERRUPT: Waiting for feedback...",
" ", // Blank line after
);
}
}
return logs;
}
/**
* Formats a log chunk for debug purposes, showing all raw data.
* This should only be used during development.
*/
export function formatDebugLog(chunk: LogChunk | string): string {
if (typeof chunk === "string") return chunk;
return JSON.stringify(chunk, null, 2);
}

237
apps/cli/src/streaming.ts Normal file
View file

@ -0,0 +1,237 @@
import { Client, StreamMode } from "@langchain/langgraph-sdk";
import { v4 as uuidv4 } from "uuid";
import { encryptSecret } from "@open-swe/shared/crypto";
import {
MANAGER_GRAPH_ID,
GITHUB_TOKEN_COOKIE,
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_INSTALLATION_NAME,
GITHUB_INSTALLATION_ID,
OPEN_SWE_STREAM_MODE,
} from "@open-swe/shared/constants";
import {
getAccessToken,
getInstallationAccessToken,
getInstallationId,
} from "./auth-server.js";
import { formatDisplayLog } from "./logger.js";
import { isAgentInboxInterruptSchema } from "@open-swe/shared/agent-inbox-interrupt";
const LANGGRAPH_URL = process.env.LANGGRAPH_URL || "http://localhost:2024";
interface StreamingCallbacks {
setLogs: (updater: (prev: string[]) => string[]) => void; // eslint-disable-line no-unused-vars
setPlannerThreadId: (id: string) => void; // eslint-disable-line no-unused-vars
setStreamingPhase: (phase: "streaming" | "awaitingFeedback" | "done") => void; // eslint-disable-line no-unused-vars
setLoadingLogs: (loading: boolean) => void; // eslint-disable-line no-unused-vars
setClient: (client: Client) => void; // eslint-disable-line no-unused-vars
setThreadId: (id: string) => void; // eslint-disable-line no-unused-vars
}
export class StreamingService {
private callbacks: StreamingCallbacks;
constructor(callbacks: StreamingCallbacks) {
this.callbacks = callbacks;
}
private async handleProgrammerStream(
client: Client,
programmerThreadId: string,
programmerRunId: string,
) {
for await (const programmerChunk of client.runs.joinStream(
programmerThreadId,
programmerRunId,
{
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
},
)) {
if (programmerChunk.event === "updates") {
const formatted = formatDisplayLog(programmerChunk);
if (formatted.length > 0) {
this.callbacks.setLogs((prev) => [...prev, ...formatted]);
}
}
}
}
private async handlePlannerStream(
client: Client,
plannerThreadId: string,
plannerRunId: string,
): Promise<{ needsFeedback: boolean }> {
let programmerStreamed = false;
for await (const subChunk of client.runs.joinStream(
plannerThreadId,
plannerRunId,
{
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
},
)) {
if (subChunk.event === "updates") {
const formatted = formatDisplayLog(subChunk);
// Filter out human messages from planner stream (already logged in manager)
const filteredFormatted = formatted.filter(
(log) => !log.startsWith("[HUMAN]"),
);
if (filteredFormatted.length > 0) {
this.callbacks.setLogs((prev) => [...prev, ...filteredFormatted]);
}
}
// Check for programmer session
if (
!programmerStreamed &&
subChunk.data?.programmerSession?.threadId &&
typeof subChunk.data.programmerSession.threadId === "string" &&
typeof subChunk.data.programmerSession.runId === "string"
) {
programmerStreamed = true;
await this.handleProgrammerStream(
client,
subChunk.data.programmerSession.threadId,
subChunk.data.programmerSession.runId,
);
}
// Detect HumanInterrupt in planner stream
const interruptArr =
subChunk.data && Array.isArray(subChunk.data["__interrupt__"])
? subChunk.data["__interrupt__"]
: undefined;
const firstInterruptValue =
interruptArr && interruptArr[0] && interruptArr[0].value
? interruptArr[0].value
: undefined;
if (isAgentInboxInterruptSchema(firstInterruptValue)) {
return { needsFeedback: true };
}
}
return { needsFeedback: false };
}
private async startManagerStream(
client: Client,
threadId: string,
runId: string,
) {
let plannerStreamed = false;
for await (const chunk of client.runs.joinStream(threadId, runId)) {
if (chunk.event === "updates") {
const formatted = formatDisplayLog(chunk);
if (formatted.length > 0) {
this.callbacks.setLogs((prev) => {
if (prev.length === 0) {
this.callbacks.setLoadingLogs(false);
}
return [...prev, ...formatted];
});
}
}
// Check for plannerSession
if (
!plannerStreamed &&
chunk.data &&
chunk.data.plannerSession &&
typeof chunk.data.plannerSession.threadId === "string" &&
typeof chunk.data.plannerSession.runId === "string"
) {
plannerStreamed = true;
this.callbacks.setPlannerThreadId(chunk.data.plannerSession.threadId);
const result = await this.handlePlannerStream(
client,
chunk.data.plannerSession.threadId,
chunk.data.plannerSession.runId,
);
if (result.needsFeedback) {
this.callbacks.setStreamingPhase("awaitingFeedback");
return; // Pause streaming, let React render feedback prompt
}
}
}
this.callbacks.setStreamingPhase("done");
}
async startNewSession(prompt: string, selectedRepo: any) {
this.callbacks.setLogs(() => []);
this.callbacks.setLoadingLogs(true);
try {
const userAccessToken = getAccessToken();
const installationAccessToken = await getInstallationAccessToken();
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!userAccessToken || !installationAccessToken || !encryptionKey) {
this.callbacks.setLogs(() => [
`Missing secrets: ${userAccessToken ? "" : "userAccessToken, "}${installationAccessToken ? "" : "installationAccessToken, "}${encryptionKey ? "" : "encryptionKey"}`,
]);
return;
}
const encryptedUserToken = encryptSecret(userAccessToken, encryptionKey);
const encryptedInstallationToken = encryptSecret(
installationAccessToken,
encryptionKey,
);
const [owner, repoName] = selectedRepo.full_name.split("/");
const runInput = {
messages: [
{
id: uuidv4(),
type: "human",
content: [{ type: "text", text: prompt }],
},
],
targetRepository: {
owner,
repo: repoName,
branch: selectedRepo.default_branch || "main",
},
autoAcceptPlan: false,
};
const installationId = getInstallationId();
const newClient = new Client({
apiUrl: LANGGRAPH_URL,
defaultHeaders: {
[GITHUB_TOKEN_COOKIE]: encryptedUserToken,
[GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptedInstallationToken,
[GITHUB_INSTALLATION_NAME]: owner,
[GITHUB_INSTALLATION_ID]: installationId,
},
});
this.callbacks.setClient(newClient);
const thread = await newClient.threads.create();
const threadId = thread.thread_id;
this.callbacks.setThreadId(threadId);
const run = await newClient.runs.create(threadId, MANAGER_GRAPH_ID, {
input: runInput,
config: { recursion_limit: 400 },
ifNotExists: "create",
streamResumable: true,
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
});
await this.startManagerStream(newClient, threadId, run.run_id);
} catch (err: any) {
this.callbacks.setLogs((prev) => [
...prev,
`Error during streaming: ${err.message}`,
]);
this.callbacks.setLoadingLogs(false);
} finally {
this.callbacks.setLoadingLogs(false);
}
}
}

121
apps/cli/src/utils.ts Normal file
View file

@ -0,0 +1,121 @@
/**
* Utility functions for CLI app
*/
import { Client, StreamMode } from "@langchain/langgraph-sdk";
import { encryptSecret } from "@open-swe/shared/crypto";
import {
OPEN_SWE_STREAM_MODE,
PLANNER_GRAPH_ID,
} from "@open-swe/shared/constants";
import {
getAccessToken,
getInstallationAccessToken,
getInstallationId,
} from "./auth-server.js";
import { formatDisplayLog } from "./logger.js";
const LANGGRAPH_URL = process.env.LANGGRAPH_URL || "http://localhost:2024";
/**
* Submit feedback to the planner
*/
export async function submitFeedback({
plannerFeedback,
plannerThreadId,
selectedRepo,
setLogs,
setPlannerFeedback,
}: {
plannerFeedback: string;
plannerThreadId: string;
selectedRepo: any;
setLogs: (updater: (prev: string[]) => string[]) => void; // eslint-disable-line no-unused-vars
setPlannerFeedback: () => void;
}) {
try {
const userAccessToken = getAccessToken();
const installationAccessToken = await getInstallationAccessToken();
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!userAccessToken || !installationAccessToken || !encryptionKey) {
setLogs((prev) => [
...prev,
"Missing access tokens for feedback submission",
]);
return;
}
const encryptedUserToken = encryptSecret(userAccessToken, encryptionKey);
const encryptedInstallationToken = encryptSecret(
installationAccessToken,
encryptionKey,
);
const [owner] = selectedRepo?.full_name.split("/") || [];
const installationId = getInstallationId();
const client = new Client({
apiUrl: LANGGRAPH_URL,
defaultHeaders: {
"x-github-access-token": encryptedUserToken,
"x-github-installation-token": encryptedInstallationToken,
"x-github-installation-name": owner,
"x-github-installation-id": installationId,
},
});
const formatted = formatDisplayLog(`Human feedback: ${plannerFeedback}`);
if (formatted.length > 0) {
setLogs((prev) => [...prev, ...formatted]);
}
// Create a new stream with the feedback
const stream = await client.runs.stream(plannerThreadId, PLANNER_GRAPH_ID, {
command: {
resume: [
{
type: plannerFeedback === "approve" ? "accept" : "ignore",
args: null,
},
],
},
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
});
let programmerStreamed = false;
// Process the stream response
for await (const chunk of stream) {
const formatted = formatDisplayLog(chunk);
if (formatted.length > 0) {
setLogs((prev) => [...prev, ...formatted]);
}
// Check for programmer session in the resumed planner stream
const chunkData = chunk.data as any;
if (
!programmerStreamed &&
chunkData?.programmerSession?.threadId &&
typeof chunkData.programmerSession.threadId === "string" &&
typeof chunkData.programmerSession.runId === "string"
) {
programmerStreamed = true;
// Join programmer stream
for await (const programmerChunk of client.runs.joinStream(
chunkData.programmerSession.threadId,
chunkData.programmerSession.runId,
)) {
const formatted = formatDisplayLog(programmerChunk);
if (formatted.length > 0) {
setLogs((prev) => [...prev, ...formatted]);
}
}
}
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
setLogs((prev) => [...prev, `Error submitting feedback: ${errorMessage}`]);
} finally {
// Clear feedback state
setPlannerFeedback();
}
}

View file

@ -7,6 +7,7 @@
"outDir": "dist",
"jsx": "react-jsx",
"strict": true,
"types": ["node"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true

View file

@ -33,11 +33,15 @@ import { getDefaultHeaders } from "../../../../utils/default-headers.js";
import { BASE_CLASSIFICATION_SCHEMA } from "./schemas.js";
import { getPlansFromIssue } from "../../../../utils/github/issue-task.js";
import { HumanResponse } from "@langchain/langgraph/prebuilt";
import { PLANNER_GRAPH_ID } from "@open-swe/shared/constants";
import {
OPEN_SWE_STREAM_MODE,
PLANNER_GRAPH_ID,
} from "@open-swe/shared/constants";
import { createLogger, LogLevel } from "../../../../utils/logger.js";
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
import { createClassificationPromptAndToolSchema } from "./utils.js";
import { RequestSource } from "../../../../constants.js";
import { StreamMode } from "@langchain/langgraph-sdk";
const logger = createLogger(LogLevel.INFO, "ClassifyMessage");
@ -263,7 +267,7 @@ export async function classifyMessage(
command: {
resume: plannerResume,
},
streamMode: ["values", "messages-tuple", "custom"],
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
},
);
newPlannerId = newPlannerRun.run_id;

View file

@ -5,7 +5,10 @@ import {
ManagerGraphUpdate,
} from "@open-swe/shared/open-swe/manager/types";
import { createIssueFieldsFromMessages } from "../utils/generate-issue-fields.js";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
import {
MANAGER_GRAPH_ID,
OPEN_SWE_STREAM_MODE,
} from "@open-swe/shared/constants";
import { createLangGraphClient } from "../../../utils/langgraph-client.js";
import { createIssue } from "../../../utils/github/api.js";
import { getGitHubTokensFromConfig } from "../../../utils/github-tokens.js";
@ -20,6 +23,7 @@ import {
import { getBranchName } from "../../../utils/github/git.js";
import { getDefaultHeaders } from "../../../utils/default-headers.js";
import { getCustomConfigurableFields } from "../../../utils/config.js";
import { StreamMode } from "@langchain/langgraph-sdk";
/**
* Create new manager session.
@ -92,7 +96,7 @@ ${ISSUE_CONTENT_CLOSE_TAG}`,
},
ifNotExists: "create",
streamResumable: true,
streamMode: ["values", "messages-tuple", "custom"],
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
});
return {

View file

@ -5,13 +5,17 @@ import {
ManagerGraphUpdate,
} from "@open-swe/shared/open-swe/manager/types";
import { createLangGraphClient } from "../../../utils/langgraph-client.js";
import { PLANNER_GRAPH_ID } from "@open-swe/shared/constants";
import {
OPEN_SWE_STREAM_MODE,
PLANNER_GRAPH_ID,
} from "@open-swe/shared/constants";
import { createLogger, LogLevel } from "../../../utils/logger.js";
import { getBranchName } from "../../../utils/github/git.js";
import { PlannerGraphUpdate } from "@open-swe/shared/open-swe/planner/types";
import { getDefaultHeaders } from "../../../utils/default-headers.js";
import { getCustomConfigurableFields } from "../../../utils/config.js";
import { getRecentUserRequest } from "../../../utils/user-request.js";
import { StreamMode } from "@langchain/langgraph-sdk";
const logger = createLogger(LogLevel.INFO, "StartPlanner");
@ -54,7 +58,7 @@ export async function startPlanner(
ifNotExists: "create",
multitaskStrategy: "enqueue",
streamResumable: true,
streamMode: ["values", "messages-tuple", "custom"],
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
},
);

View file

@ -1,6 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import { AIMessage, BaseMessage } from "@langchain/core/messages";
import { Command, END, interrupt } from "@langchain/langgraph";
import { StreamMode } from "@langchain/langgraph-sdk";
import {
GraphUpdate,
GraphConfig,
@ -23,6 +24,7 @@ import {
PLAN_INTERRUPT_DELIMITER,
DO_NOT_RENDER_ID_PREFIX,
PROGRAMMER_GRAPH_ID,
OPEN_SWE_STREAM_MODE,
} from "@open-swe/shared/constants";
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
import { createLangGraphClient } from "../../../utils/langgraph-client.js";
@ -200,7 +202,7 @@ async function startProgrammerRun(input: {
ifNotExists: "create",
streamResumable: true,
streamSubgraphs: true,
streamMode: ["values", "messages-tuple", "custom"],
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
},
);

View file

@ -12,6 +12,7 @@ import {
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
MANAGER_GRAPH_ID,
OPEN_SWE_STREAM_MODE,
} from "@open-swe/shared/constants";
import { encryptSecret } from "@open-swe/shared/crypto";
import { HumanMessage } from "@langchain/core/messages";
@ -24,6 +25,7 @@ import {
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
import { RequestSource } from "../../constants.js";
import { isAllowedUser } from "@open-swe/shared/github/allowed-users";
import { StreamMode } from "@langchain/langgraph-sdk";
const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook");
@ -202,7 +204,7 @@ webhooks.on("issues.labeled", async ({ payload }) => {
config,
ifNotExists: "create",
streamResumable: true,
streamMode: ["values", "messages-tuple", "custom"],
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
});
logger.info("Created new run from GitHub issue.", {

View file

@ -8,6 +8,7 @@ import { Loader2, Send } from "lucide-react";
import { RepositoryBranchSelectors } from "../github/repo-branch-selectors";
import { Button } from "../ui/button";
import { useStream } from "@langchain/langgraph-sdk/react";
import { StreamMode } from "@langchain/langgraph-sdk";
import { useRouter } from "next/navigation";
import { useGitHubAppProvider } from "@/providers/GitHubApp";
import { GraphState } from "@open-swe/shared/open-swe/types";
@ -17,6 +18,7 @@ import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
import {
API_KEY_REQUIRED_MESSAGE,
MANAGER_GRAPH_ID,
OPEN_SWE_STREAM_MODE,
} from "@open-swe/shared/constants";
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
import { useDraftStorage } from "@/hooks/useDraftStorage";
@ -104,7 +106,7 @@ export function TerminalInput({
},
ifNotExists: "create",
streamResumable: true,
streamMode: ["values", "messages-tuple", "custom"],
streamMode: OPEN_SWE_STREAM_MODE as StreamMode[],
},
);

View file

@ -0,0 +1,19 @@
import { HumanInterrupt } from "@langchain/langgraph/prebuilt";
export function isAgentInboxInterruptSchema(
value: unknown,
): value is HumanInterrupt | HumanInterrupt[] {
const valueAsObject = Array.isArray(value) ? value[0] : value;
return (
valueAsObject &&
typeof valueAsObject === "object" &&
"action_request" in valueAsObject &&
typeof valueAsObject.action_request === "object" &&
"config" in valueAsObject &&
typeof valueAsObject.config === "object" &&
"allow_respond" in valueAsObject.config &&
"allow_accept" in valueAsObject.config &&
"allow_edit" in valueAsObject.config &&
"allow_ignore" in valueAsObject.config
);
}

View file

@ -42,3 +42,11 @@ export const DEFAULT_MCP_SERVERS = {
export const API_KEY_REQUIRED_MESSAGE =
"Unknown users must provide API keys to use the Open SWE demo application";
export const OPEN_SWE_STREAM_MODE = [
"values",
"updates",
"messages",
"messages-tuple",
"custom",
];

View file

@ -4143,11 +4143,14 @@ __metadata:
dependencies:
"@eslint/eslintrc": ^3.1.0
"@eslint/js": ^9.19.0
"@langchain/langgraph-sdk": ^0.0.95
"@open-swe/shared": "*"
"@tsconfig/recommended": ^1.0.8
"@types/express": ^4.17.21
"@types/node": ^22.13.5
"@types/node": ^24.1.0
"@types/node-fetch": ^2.6.4
"@types/react": ^19.1.8
"@types/uuid": ^10.0.0
"@typescript-eslint/eslint-plugin": ^8.38.0
"@typescript-eslint/parser": ^8.38.0
dotenv: ^16.6.1
@ -4165,6 +4168,7 @@ __metadata:
react: ^19.1.0
tsx: ^4.20.3
typescript: ^5.8.3
uuid: ^10.0.0
languageName: unknown
linkType: soft
@ -7051,6 +7055,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^24.1.0":
version: 24.1.0
resolution: "@types/node@npm:24.1.0"
dependencies:
undici-types: ~7.8.0
checksum: 01f9a97909eec619d937af3bc00ac49461e1846656b4d060f648145df06508eb6d77c200637a792481ee93a73976ced46cfbbdfe307e79be0f58ccdc415dfcd2
languageName: node
linkType: hard
"@types/prismjs@npm:^1.26.0":
version: 1.26.5
resolution: "@types/prismjs@npm:1.26.5"