Compare commits

...

5 Commits

Author SHA1 Message Date
Hugo Dutka 5340e50e48 refactor 2025-06-30 16:00:00 +02:00
Hugo Dutka 00f11ab007 remove tmux 2025-06-27 15:26:53 +02:00
Ben Potter 9802abd650 further increase 2025-06-25 17:29:52 -05:00
Ben Potter 9f2f591dc3 increase timeouts for claude code web
this is a hack for a demo... preinstall and postinstall script will increase/decrease this time and I aim to find a better way to do this in the feature
2025-06-25 17:27:35 -05:00
Hugo Dutka 6c3c2f067d agentapi and ai task support 2025-06-24 19:59:12 +02:00
9 changed files with 675 additions and 188 deletions
@@ -0,0 +1,322 @@
import {
test,
afterEach,
expect,
describe,
setDefaultTimeout,
beforeAll,
} from "bun:test";
import path from "path";
import {
execContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
writeCoder,
writeFileContainer,
} from "~test";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
// Cleanup logic depends on the fact that bun's built-in test runner
// runs tests sequentially.
// https://bun.sh/docs/test/discovery#execution-order
// Weird things would happen if tried to run tests in parallel.
// One test could clean up resources that another test was still using.
afterEach(async () => {
// reverse the cleanup functions so that they are run in the correct order
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
const setupContainer = async ({
image,
vars,
}: {
image?: string;
vars?: Record<string, string>;
} = {}) => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
...vars,
});
const coderScript = findResourceInstance(state, "coder_script");
const id = await runContainer(image ?? "codercom/enterprise-node:latest");
registerCleanup(() => removeContainer(id));
return { id, coderScript };
};
const loadTestFile = async (...relativePath: string[]) => {
return await Bun.file(
path.join(import.meta.dir, "testdata", ...relativePath),
).text();
};
const writeExecutable = async ({
containerId,
filePath,
content,
}: {
containerId: string;
filePath: string;
content: string;
}) => {
await writeFileContainer(containerId, filePath, content, {
user: "root",
});
await execContainer(
containerId,
["bash", "-c", `chmod 755 ${filePath}`],
["--user", "root"],
);
};
const writeAgentAPIMockControl = async ({
containerId,
content,
}: {
containerId: string;
content: string;
}) => {
await writeFileContainer(containerId, "/tmp/agentapi-mock.control", content, {
user: "coder",
});
};
interface SetupProps {
skipAgentAPIMock?: boolean;
skipClaudeMock?: boolean;
}
const projectDir = "/home/coder/project";
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
const { id, coderScript } = await setupContainer({
vars: {
experiment_report_tasks: "true",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
install_claude_code: "false",
agentapi_version: "preview",
folder: projectDir,
},
});
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
// the module script assumes that there is a coder executable in the PATH
await writeCoder(id, await loadTestFile("coder-mock.js"));
if (!props?.skipAgentAPIMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/agentapi",
content: await loadTestFile("agentapi-mock.js"),
});
}
if (!props?.skipClaudeMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/claude",
content: await loadTestFile("claude-mock.js"),
});
}
await writeExecutable({
containerId: id,
filePath: "/home/coder/script.sh",
content: coderScript.script,
});
return { id };
};
const expectAgentAPIStarted = async (id: string) => {
const resp = await execContainer(id, [
"bash",
"-c",
`curl -fs -o /dev/null "http://localhost:3284/status"`,
]);
if (resp.exitCode !== 0) {
console.log("agentapi not started");
console.log(resp.stdout);
console.log(resp.stderr);
}
expect(resp.exitCode).toBe(0);
};
const execModuleScript = async (id: string) => {
const resp = await execContainer(id, [
"bash",
"-c",
`set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`,
]);
if (resp.exitCode !== 0) {
console.log(resp.stdout);
console.log(resp.stderr);
}
return resp;
};
// increase the default timeout to 60 seconds
setDefaultTimeout(60 * 1000);
// we don't run these tests in CI because they take too long and make network
// calls. they are dedicated for local development.
describe("claude-code", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
// test that the script runs successfully if claude starts without any errors
test("happy-path", async () => {
const { id } = await setup();
const resp = await execContainer(id, [
"bash",
"-c",
"sudo /home/coder/script.sh",
]);
expect(resp.exitCode).toBe(0);
await expectAgentAPIStarted(id);
});
// test that the script removes lastSessionId from the .claude.json file
test("last-session-id-removed", async () => {
const { id } = await setup();
await writeFileContainer(
id,
"/home/coder/.claude.json",
JSON.stringify({
projects: {
[projectDir]: {
lastSessionId: "123",
},
},
}),
);
const catResp = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude.json",
]);
expect(catResp.exitCode).toBe(0);
expect(catResp.stdout).toContain("lastSessionId");
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
await expectAgentAPIStarted(id);
const catResp2 = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude.json",
]);
expect(catResp2.exitCode).toBe(0);
expect(catResp2.stdout).not.toContain("lastSessionId");
});
// test that the script handles a .claude.json file that doesn't contain
// a lastSessionId field
test("last-session-id-not-found", async () => {
const { id } = await setup();
await writeFileContainer(
id,
"/home/coder/.claude.json",
JSON.stringify({
projects: {
"/home/coder": {},
},
}),
);
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
await expectAgentAPIStarted(id);
const catResp = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(catResp.exitCode).toBe(0);
expect(catResp.stdout).toContain(
"No lastSessionId found in .claude.json - nothing to do",
);
});
// test that if claude fails to run with the --continue flag and returns a
// no conversation found error, then the module script retries without the flag
test("no-conversation-found", async () => {
const { id } = await setup();
await writeAgentAPIMockControl({
containerId: id,
content: "no-conversation-found",
});
// check that mocking works
const respAgentAPI = await execContainer(id, [
"bash",
"-c",
"agentapi --continue",
]);
expect(respAgentAPI.exitCode).toBe(1);
expect(respAgentAPI.stderr).toContain("No conversation found to continue");
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
await expectAgentAPIStarted(id);
});
test("install-agentapi", async () => {
const { id } = await setup({ skipAgentAPIMock: true });
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
await expectAgentAPIStarted(id);
const respAgentAPI = await execContainer(id, [
"bash",
"-c",
"agentapi --version",
]);
expect(respAgentAPI.exitCode).toBe(0);
});
// the coder binary should be executed with specific env vars
// that are set by the module script
test("coder-env-vars", async () => {
const { id } = await setup();
const respModuleScript = await execModuleScript(id);
expect(respModuleScript.exitCode).toBe(0);
const respCoderMock = await execContainer(id, [
"bash",
"-c",
"cat /home/coder/coder-mock-output.json",
]);
if (respCoderMock.exitCode !== 0) {
console.log(respCoderMock.stdout);
console.log(respCoderMock.stderr);
}
expect(respCoderMock.exitCode).toBe(0);
expect(JSON.parse(respCoderMock.stdout)).toEqual({
statusSlug: "ccw",
agentApiUrl: "http://localhost:3284",
});
});
});
+119 -182
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 2.7"
}
}
}
@@ -54,16 +54,22 @@ variable "claude_code_version" {
default = "latest"
}
variable "experiment_use_screen" {
variable "experiment_cli_app" {
type = bool
description = "Whether to use screen for running Claude Code in the background."
description = "Whether to create the CLI workspace app."
default = false
}
variable "experiment_use_tmux" {
type = bool
description = "Whether to use tmux instead of screen for running Claude Code in the background."
default = false
variable "experiment_cli_app_order" {
type = number
description = "The order of the CLI workspace app."
default = null
}
variable "experiment_cli_app_group" {
type = string
description = "The group of the CLI workspace app."
default = null
}
variable "experiment_report_tasks" {
@@ -84,21 +90,29 @@ variable "experiment_post_install_script" {
default = null
}
variable "experiment_tmux_session_persistence" {
variable "install_agentapi" {
type = bool
description = "Whether to enable tmux session persistence across workspace restarts."
default = false
description = "Whether to install AgentAPI."
default = true
}
variable "experiment_tmux_session_save_interval" {
variable "agentapi_version" {
type = string
description = "How often to save tmux sessions in minutes."
default = "15"
description = "The version of AgentAPI to install."
default = "v0.2.2"
}
locals {
encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
# we have to trim the slash because otherwise coder exp mcp will
# set up an invalid claude config
workdir = trimsuffix(var.folder, "/")
encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh"))
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js"))
claude_code_app_slug = "ccw"
}
# Install and Initialize Claude Code
@@ -109,35 +123,16 @@ resource "coder_script" "claude_code" {
script = <<-EOT
#!/bin/bash
set -e
set -x
command_exists() {
command -v "$1" >/dev/null 2>&1
}
install_tmux() {
echo "Installing tmux..."
if command_exists apt-get; then
sudo apt-get update && sudo apt-get install -y tmux
elif command_exists yum; then
sudo yum install -y tmux
elif command_exists dnf; then
sudo dnf install -y tmux
elif command_exists pacman; then
sudo pacman -S --noconfirm tmux
elif command_exists apk; then
sudo apk add tmux
else
echo "Error: Unable to install tmux automatically. Package manager not recognized."
exit 1
fi
}
if [ ! -d "${var.folder}" ]; then
echo "Warning: The specified folder '${var.folder}' does not exist."
if [ ! -d "${local.workdir}" ]; then
echo "Warning: The specified folder '${local.workdir}' does not exist."
echo "Creating the folder..."
# The folder must exist before tmux is started or else claude will start
# in the home directory.
mkdir -p "${var.folder}"
mkdir -p "${local.workdir}"
echo "Folder created successfully."
fi
if [ -n "${local.encoded_pre_install_script}" ]; then
@@ -176,9 +171,58 @@ resource "coder_script" "claude_code" {
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
fi
if ! command_exists node; then
echo "Error: Node.js is not installed. Please install Node.js manually."
exit 1
fi
# Install AgentAPI if enabled
if [ "${var.install_agentapi}" = "true" ]; then
echo "Installing AgentAPI..."
arch=$(uname -m)
if [ "$arch" = "x86_64" ]; then
binary_name="agentapi-linux-amd64"
elif [ "$arch" = "aarch64" ]; then
binary_name="agentapi-linux-arm64"
else
echo "Error: Unsupported architecture: $arch"
exit 1
fi
curl \
--retry 5 \
--retry-delay 5 \
--fail \
--retry-all-errors \
-L \
-C - \
-o agentapi \
"https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name"
chmod +x agentapi
sudo mv agentapi /usr/local/bin/agentapi
fi
if ! command_exists agentapi; then
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
exit 1
fi
# this must be kept in sync with the agentapi-start.sh script
module_path="$HOME/.claude-module"
mkdir -p "$module_path/scripts"
# save the prompt for the agentapi start command
echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > "$module_path/prompt.txt"
echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh"
echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh"
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js"
chmod +x "$module_path/scripts/agentapi-start.sh"
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Claude Code to report tasks via Coder MCP..."
coder exp mcp configure claude-code ${var.folder}
export CODER_MCP_APP_STATUS_SLUG="${local.claude_code_app_slug}"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
coder exp mcp configure claude-code "${local.workdir}"
fi
if [ -n "${local.encoded_post_install_script}" ]; then
@@ -188,133 +232,43 @@ resource "coder_script" "claude_code" {
/tmp/post_install.sh
fi
if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then
echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously."
echo "Please set only one of them to true."
if ! command_exists claude; then
echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually."
exit 1
fi
if [ "${var.experiment_tmux_session_persistence}" = "true" ] && [ "${var.experiment_use_tmux}" != "true" ]; then
echo "Error: Session persistence requires tmux to be enabled."
echo "Please set experiment_use_tmux = true when using session persistence."
exit 1
fi
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.experiment_use_tmux}" = "true" ]; then
if ! command_exists tmux; then
install_tmux
fi
if [ "${var.experiment_tmux_session_persistence}" = "true" ]; then
echo "Setting up tmux session persistence..."
if ! command_exists git; then
echo "Git not found, installing git..."
if command_exists apt-get; then
sudo apt-get update && sudo apt-get install -y git
elif command_exists yum; then
sudo yum install -y git
elif command_exists dnf; then
sudo dnf install -y git
elif command_exists pacman; then
sudo pacman -S --noconfirm git
elif command_exists apk; then
sudo apk add git
else
echo "Error: Unable to install git automatically. Package manager not recognized."
echo "Please install git manually to enable session persistence."
exit 1
fi
fi
mkdir -p ~/.tmux/plugins
if [ ! -d ~/.tmux/plugins/tpm ]; then
git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
fi
cat > ~/.tmux.conf << EOF
# Claude Code tmux persistence configuration
set -g @plugin 'tmux-plugins/tmux-resurrect'
set -g @plugin 'tmux-plugins/tmux-continuum'
# Configure session persistence
set -g @resurrect-processes ':all:'
set -g @resurrect-capture-pane-contents 'on'
set -g @resurrect-save-bash-history 'on'
set -g @continuum-restore 'on'
set -g @continuum-save-interval '${var.experiment_tmux_session_save_interval}'
set -g @continuum-boot 'on'
set -g @continuum-save-on 'on'
# Initialize plugin manager
run '~/.tmux/plugins/tpm/tpm'
EOF
~/.tmux/plugins/tpm/scripts/install_plugins.sh
fi
echo "Running Claude Code in the background with tmux..."
touch "$HOME/.claude-code.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.experiment_tmux_session_persistence}" = "true" ]; then
sleep 3
if ! tmux has-session -t claude-code 2>/dev/null; then
# Only create a new session if one doesn't exist
tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions \"$CODER_MCP_CLAUDE_TASK_PROMPT\""
fi
else
if ! tmux has-session -t claude-code 2>/dev/null; then
tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions \"$CODER_MCP_CLAUDE_TASK_PROMPT\""
fi
fi
fi
if [ "${var.experiment_use_screen}" = "true" ]; then
echo "Running Claude Code in the background..."
if ! command_exists screen; then
echo "Error: screen is not installed. Please install screen manually."
exit 1
fi
touch "$HOME/.claude-code.log"
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log"
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log"
echo "multiuser on" >> "$HOME/.screenrc"
fi
if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log"
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
screen -U -dmS claude-code bash -c '
cd ${var.folder}
claude --dangerously-skip-permissions "$CODER_MCP_CLAUDE_TASK_PROMPT" | tee -a "$HOME/.claude-code.log"
exec bash
'
else
if ! command_exists claude; then
echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually."
exit 1
fi
fi
cd "${local.workdir}"
nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh"
EOT
run_on_start = true
}
resource "coder_app" "claude_code_web" {
# use a short slug to mitigate https://github.com/coder/coder/issues/15178
slug = local.claude_code_app_slug
display_name = "Claude Code Web"
agent_id = var.agent_id
url = "http://localhost:3284/"
icon = var.icon
order = var.order
group = var.group
subdomain = true
healthcheck {
url = "http://localhost:3284/status"
interval = 3
threshold = 20
}
}
resource "coder_app" "claude_code" {
count = var.experiment_cli_app ? 1 : 0
slug = "claude-code"
display_name = "Claude Code"
display_name = "Claude Code CLI"
agent_id = var.agent_id
command = <<-EOT
#!/bin/bash
@@ -323,32 +277,15 @@ resource "coder_app" "claude_code" {
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.experiment_use_tmux}" = "true" ]; then
if tmux has-session -t claude-code 2>/dev/null; then
echo "Attaching to existing Claude Code tmux session." | tee -a "$HOME/.claude-code.log"
# If Claude isn't running in the session, start it without the prompt
if ! tmux list-panes -t claude-code -F '#{pane_current_command}' | grep -q "claude"; then
tmux send-keys -t claude-code "cd ${var.folder} && claude -c --dangerously-skip-permissions" C-m
fi
tmux attach-session -t claude-code
else
echo "Starting a new Claude Code tmux session." | tee -a "$HOME/.claude-code.log"
tmux new-session -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions | tee -a \"$HOME/.claude-code.log\"; exec bash"
fi
elif [ "${var.experiment_use_screen}" = "true" ]; then
if screen -list | grep -q "claude-code"; then
echo "Attaching to existing Claude Code screen session." | tee -a "$HOME/.claude-code.log"
screen -xRR claude-code
else
echo "Starting a new Claude Code screen session." | tee -a "$HOME/.claude-code.log"
screen -S claude-code bash -c 'claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash'
fi
else
cd ${var.folder}
claude
fi
agentapi attach
EOT
icon = var.icon
order = var.order
group = var.group
order = var.experiment_cli_app_order
group = var.experiment_cli_app_group
}
resource "coder_ai_task" "claude_code" {
sidebar_app {
id = coder_app.claude_code_web.id
}
}
@@ -0,0 +1,63 @@
#!/bin/bash
set -o errexit
set -o pipefail
# this must be kept in sync with the main.tf file
module_path="$HOME/.claude-module"
scripts_dir="$module_path/scripts"
log_file_path="$module_path/agentapi.log"
# if the first argument is not empty, start claude with the prompt
if [ -n "$1" ]; then
cp "$module_path/prompt.txt" /tmp/claude-code-prompt
else
rm -f /tmp/claude-code-prompt
fi
# if the log file already exists, archive it
if [ -f "$log_file_path" ]; then
mv "$log_file_path" "$log_file_path"".$(date +%s)"
fi
# see the remove-last-session-id.js script for details
# about why we need it
# avoid exiting if the script fails
node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true
# we'll be manually handling errors from this point on
set +o errexit
function start_agentapi() {
local continue_flag="$1"
local prompt_subshell='"$(cat /tmp/claude-code-prompt)"'
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
# visible in the terminal screen by default.
agentapi server --term-width 67 --term-height 1190 -- \
bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \
> "$log_file_path" 2>&1
}
echo "Starting AgentAPI..."
# attempt to start claude with the --continue flag
start_agentapi --continue
exit_code=$?
echo "First AgentAPI exit code: $exit_code"
if [ $exit_code -eq 0 ]; then
exit 0
fi
# if there was no conversation to continue, claude exited with an error.
# start claude without the --continue flag.
if grep -q "No conversation found to continue" "$log_file_path"; then
echo "AgentAPI with --continue flag failed, starting claude without it."
start_agentapi
exit_code=$?
fi
echo "Second AgentAPI exit code: $exit_code"
exit $exit_code
@@ -0,0 +1,30 @@
#!/bin/bash
set -o errexit
set -o pipefail
# This script waits for the agentapi server to start on port 3284.
# It considers the server started after 3 consecutive successful responses.
agentapi_started=false
echo "Waiting for agentapi server to start on port 3284..."
for i in $(seq 1 150); do
for j in $(seq 1 3); do
sleep 0.1
if curl -fs -o /dev/null "http://localhost:3284/status"; then
echo "agentapi response received ($j/3)"
else
echo "agentapi server not responding ($i/15)"
continue 2
fi
done
agentapi_started=true
break
done
if [ "$agentapi_started" != "true" ]; then
echo "Error: agentapi server did not start on port 3284 after 15 seconds."
exit 1
fi
echo "agentapi server started on port 3284."
@@ -0,0 +1,40 @@
// If lastSessionId is present in .claude.json, claude --continue will start a
// conversation starting from that session. The problem is that lastSessionId
// doesn't always point to the last session. The field is updated by claude only
// at the point of normal CLI exit. If Claude exits with an error, or if the user
// restarts the Coder workspace, lastSessionId will be stale, and claude --continue
// will start from an old session.
//
// If lastSessionId is missing, claude seems to accurately figure out where to
// start using the conversation history - even if the CLI previously exited with
// an error.
//
// This script removes the lastSessionId field from .claude.json.
const path = require("path")
const fs = require("fs")
const workingDirArg = process.argv[2]
if (!workingDirArg) {
console.log("No working directory provided - it must be the first argument")
process.exit(1)
}
const workingDir = path.resolve(workingDirArg)
console.log("workingDir", workingDir)
const claudeJsonPath = path.join(process.env.HOME, ".claude.json")
console.log(".claude.json path", claudeJsonPath)
if (!fs.existsSync(claudeJsonPath)) {
console.log("No .claude.json file found")
process.exit(0)
}
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"))
if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) {
delete claudeJson.projects[workingDir].lastSessionId
fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2))
console.log("Removed lastSessionId from .claude.json")
} else {
console.log("No lastSessionId found in .claude.json - nothing to do")
}
@@ -0,0 +1,34 @@
#!/usr/bin/env node
const http = require("http");
const fs = require("fs");
const args = process.argv.slice(2);
const port = 3284;
const controlFile = "/tmp/agentapi-mock.control";
let control = "";
if (fs.existsSync(controlFile)) {
control = fs.readFileSync(controlFile, "utf8");
}
if (
control === "no-conversation-found" &&
args.join(" ").includes("--continue")
) {
// this must match the error message in the agentapi-start.sh script
console.error("No conversation found to continue");
process.exit(1);
}
console.log(`starting server on port ${port}`);
http
.createServer(function (_request, response) {
response.writeHead(200);
response.end(
JSON.stringify({
status: "stable",
}),
);
})
.listen(port);
@@ -0,0 +1,9 @@
#!/usr/bin/env node
const main = async () => {
console.log("mocking claude");
// sleep for 30 minutes
await new Promise((resolve) => setTimeout(resolve, 30 * 60 * 1000));
};
main();
@@ -0,0 +1,14 @@
#!/usr/bin/env node
const fs = require("fs");
const statusSlugEnvVar = "CODER_MCP_APP_STATUS_SLUG";
const agentApiUrlEnvVar = "CODER_MCP_AI_AGENTAPI_URL";
fs.writeFileSync(
"/home/coder/coder-mock-output.json",
JSON.stringify({
statusSlug: process.env[statusSlugEnvVar] ?? "env var not set",
agentApiUrl: process.env[agentApiUrlEnvVar] ?? "env var not set",
}),
);
+44 -6
View File
@@ -30,6 +30,21 @@ export const runContainer = async (
return containerID.trim();
};
export const removeContainer = async (id: string) => {
const proc = spawn(["docker", "rm", "-f", id], {
stderr: "pipe",
stdout: "pipe",
});
const exitCode = await proc.exited;
const [stderr, stdout] = await Promise.all([
readableStreamToText(proc.stderr ?? new ReadableStream()),
readableStreamToText(proc.stdout ?? new ReadableStream()),
]);
if (exitCode !== 0) {
throw new Error(`${stderr}\n${stdout}`);
}
};
export interface scriptOutput {
exitCode: number;
stdout: string[];
@@ -279,10 +294,33 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => {
};
export const writeCoder = async (id: string, script: string) => {
const exec = await execContainer(id, [
"sh",
"-c",
`echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`,
]);
expect(exec.exitCode).toBe(0);
await writeFileContainer(id, "/usr/bin/coder", script, {
user: "root",
});
const execResult = await execContainer(
id,
["chmod", "755", "/usr/bin/coder"],
["--user", "root"],
);
expect(execResult.exitCode).toBe(0);
};
export const writeFileContainer = async (
id: string,
path: string,
content: string,
options?: {
user?: string;
},
) => {
const contentBase64 = Buffer.from(content).toString("base64");
const proc = await execContainer(
id,
["sh", "-c", `echo '${contentBase64}' | base64 -d > '${path}'`],
options?.user ? ["--user", options.user] : undefined,
);
if (proc.exitCode !== 0) {
throw new Error(`Failed to write file: ${proc.stderr}`);
}
expect(proc.exitCode).toBe(0);
};