diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 21ff81f2..4b0361e3 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -14,7 +14,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "1.4.0" + version = "2.0.0" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true @@ -30,7 +30,6 @@ module "claude-code" { ## Prerequisites - Node.js and npm must be installed in your workspace to install Claude Code -- Either `screen` or `tmux` must be installed in your workspace to run Claude Code in the background - You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. @@ -48,8 +47,6 @@ The `codercom/oss-dogfood:latest` container image can be used for testing on con > Join our [Discord channel](https://discord.gg/coder) or > [contact us](https://coder.com/contact) to get help or share feedback. -Your workspace must have either `screen` or `tmux` installed to use this. - ```tf variable "anthropic_api_key" { type = string @@ -88,49 +85,25 @@ resource "coder_agent" "main" { module "claude-code" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/claude-code/coder" - version = "1.4.0" + version = "2.0.0" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true claude_code_version = "0.2.57" # Enable experimental features - experiment_use_screen = true # Or use experiment_use_tmux = true to use tmux instead experiment_report_tasks = true } ``` -## Session Persistence (Experimental) - -Enable automatic session persistence to maintain Claude Code sessions across workspace restarts: - -```tf -module "claude-code" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/claude-code/coder" - version = "1.4.0" - agent_id = coder_agent.example.id - folder = "/home/coder" - install_claude_code = true - - # Enable tmux with session persistence - experiment_use_tmux = true - experiment_tmux_session_persistence = true - experiment_tmux_session_save_interval = "10" # Save every 10 minutes - experiment_report_tasks = true -} -``` - -Session persistence automatically saves and restores your Claude Code environment, including working directory and command history. - ## Run standalone -Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it directly without using screen or any task reporting to the Coder UI. +Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it without any task reporting to the Coder UI. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "1.4.0" + version = "2.0.0" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts new file mode 100644 index 00000000..d9538d45 --- /dev/null +++ b/registry/coder/modules/claude-code/main.test.ts @@ -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)[] = []; + +const registerCleanup = (cleanup: () => Promise) => { + 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; +} = {}) => { + 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", + }); + }); +}); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index d699b4f1..19496eff 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -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." - 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." + 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_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 - 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 + } } diff --git a/registry/coder/modules/claude-code/scripts/agentapi-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-start.sh new file mode 100644 index 00000000..c66b7f35 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-start.sh @@ -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 diff --git a/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh new file mode 100644 index 00000000..2eb84975 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh @@ -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." diff --git a/registry/coder/modules/claude-code/scripts/remove-last-session-id.js b/registry/coder/modules/claude-code/scripts/remove-last-session-id.js new file mode 100644 index 00000000..0b66edfe --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/remove-last-session-id.js @@ -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") +} diff --git a/registry/coder/modules/claude-code/testdata/agentapi-mock.js b/registry/coder/modules/claude-code/testdata/agentapi-mock.js new file mode 100644 index 00000000..4ea17b5f --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/agentapi-mock.js @@ -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); diff --git a/registry/coder/modules/claude-code/testdata/claude-mock.js b/registry/coder/modules/claude-code/testdata/claude-mock.js new file mode 100644 index 00000000..ea9f9aa9 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/claude-mock.js @@ -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(); diff --git a/registry/coder/modules/claude-code/testdata/coder-mock.js b/registry/coder/modules/claude-code/testdata/coder-mock.js new file mode 100644 index 00000000..cc479f43 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/coder-mock.js @@ -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", + }), +); diff --git a/test/test.ts b/test/test.ts index 4f413180..0de9fb04 100644 --- a/test/test.ts +++ b/test/test.ts @@ -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); };