From 6c3c2f067ddd914a6ed09d476166d2b1f5a52eeb Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 24 Jun 2025 13:33:57 +0200 Subject: [PATCH 1/3] agentapi and ai task support --- registry/coder/modules/claude-code/main.tf | 183 ++++++++++++++++++--- 1 file changed, 158 insertions(+), 25 deletions(-) diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index d699b4f..f2affa3 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" } } } @@ -96,9 +96,75 @@ variable "experiment_tmux_session_save_interval" { default = "15" } +variable "install_agentapi" { + type = bool + description = "Whether to install AgentAPI." + default = true +} + +variable "agentapi_version" { + type = string + 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_command = <<-EOT + #!/bin/bash + set -e + + # if the first argument is not empty, start claude with the prompt + if [ -n "$1" ]; then + prompt="$(cat ~/.claude-code-prompt)" + cp ~/.claude-code-prompt /tmp/claude-code-prompt + else + rm -f /tmp/claude-code-prompt + fi + + # We need to check if there's a session to use --continue. If there's no session, + # using this flag would cause claude to exit with an error. + # warning: this is a hack and will break if claude changes the format of the .claude.json file. + # Also, this solution is not ideal: a user has to quit claude in order for the session id to appear + # in .claude.json. If they just restart the workspace, the session id will not be available. + continue_flag="" + if grep -q '"lastSessionId":' ~/.claude.json; then + echo "Found a Claude Code session to continue." + continue_flag="--continue" + else + echo "No Claude Code session to continue." + fi + + # 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. + prompt_subshell='"$(cat /tmp/claude-code-prompt)"' + agentapi server --term-width 67 --term-height 1190 -- bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" + EOT + agentapi_wait_for_start_command = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + + echo "Waiting for agentapi server to start on port 3284..." + for i in $(seq 1 15); do + if lsof -i :3284 | grep -q 'LISTEN'; then + echo "agentapi server started on port 3284." + break + fi + echo "Waiting... ($i/15)" + sleep 1 + done + if ! lsof -i :3284 | grep -q 'LISTEN'; then + echo "Error: agentapi server did not start on port 3284 after 15 seconds." + exit 1 + fi + EOT + agentapi_start_command_base64 = base64encode(local.agentapi_start_command) + agentapi_wait_for_start_command_base64 = base64encode(local.agentapi_wait_for_start_command) } # Install and Initialize Claude Code @@ -132,12 +198,12 @@ resource "coder_script" "claude_code" { 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 +242,38 @@ resource "coder_script" "claude_code" { npm install -g @anthropic-ai/claude-code@${var.claude_code_version} 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 + wget "https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name" + chmod +x "$binary_name" + sudo mv "$binary_name" /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 + + # save the prompt for the agentapi start command + echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > ~/.claude-code-prompt + + echo -n "${local.agentapi_start_command_base64}" | base64 -d > ~/.agentapi-start-command + chmod +x ~/.agentapi-start-command + echo -n "${local.agentapi_wait_for_start_command_base64}" | base64 -d > ~/.agentapi-wait-for-start-command + chmod +x ~/.agentapi-wait-for-start-command + 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} + coder exp mcp configure claude-code ${local.workdir} --ai-agentapi-url http://localhost:3284 fi if [ -n "${local.encoded_post_install_script}" ]; then @@ -257,17 +352,16 @@ EOF export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 + tmux new-session -d -s agentapi-cc -c ${local.workdir} '~/.agentapi-start-command true; exec bash' + ~/.agentapi-wait-for-start-command + if [ "${var.experiment_tmux_session_persistence}" = "true" ]; then sleep 3 + fi - 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 + 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 ${local.workdir} "agentapi attach; exec bash" fi fi @@ -297,9 +391,17 @@ EOF export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 + screen -U -dmS agentapi-cc bash -c ' + cd ${local.workdir} + # setting the first argument will make claude use the prompt + ~/.agentapi-start-command true + exec bash + ' + ~/.agentapi-wait-for-start-command + 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" + cd ${local.workdir} + agentapi attach exec bash ' else @@ -312,6 +414,21 @@ EOF run_on_start = true } +resource "coder_app" "claude_code_web" { + # use a short slug to mitigate https://github.com/coder/coder/issues/15178 + slug = "ccw" + display_name = "Claude Code Web" + agent_id = var.agent_id + url = "http://localhost:3284/" + icon = var.icon + subdomain = true + healthcheck { + url = "http://localhost:3284/status" + interval = 5 + threshold = 3 + } +} + resource "coder_app" "claude_code" { slug = "claude-code" display_name = "Claude Code" @@ -324,31 +441,47 @@ resource "coder_app" "claude_code" { export LC_ALL=en_US.UTF-8 if [ "${var.experiment_use_tmux}" = "true" ]; then + if ! tmux has-session -t agentapi-cc 2>/dev/null; then + # start agentapi without claude using the prompt (no argument) + tmux new-session -d -s agentapi-cc -c ${local.workdir} '~/.agentapi-start-command; exec bash' + ~/.agentapi-wait-for-start-command + fi + 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" + tmux new-session -s claude-code -c ${local.workdir} "agentapi attach; exec bash" fi elif [ "${var.experiment_use_screen}" = "true" ]; then + if ! screen -list | grep -q "agentapi-cc"; then + screen -S agentapi-cc bash -c ' + cd ${local.workdir} + # start agentapi without claude using the prompt (no argument) + ~/.agentapi-start-command + exec bash + ' + fi 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' + screen -S claude-code bash -c 'agentapi attach; exec bash' fi else - cd ${var.folder} - claude + cd ${local.workdir} + agentapi attach fi EOT icon = var.icon order = var.order group = var.group } + +resource "coder_ai_task" "claude_code" { + sidebar_app { + id = coder_app.claude_code_web.id + } +} From f041dfeead3898b77e78dbe5ed1c5f1d352ccc9c Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 25 Jun 2025 14:07:33 +0200 Subject: [PATCH 2/3] remove tmux --- registry/coder/modules/claude-code/main.tf | 217 ++++----------------- 1 file changed, 34 insertions(+), 183 deletions(-) diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index f2affa3..494e632 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -60,12 +60,6 @@ variable "experiment_use_screen" { 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_report_tasks" { type = bool description = "Whether to enable task reporting." @@ -84,17 +78,6 @@ variable "experiment_post_install_script" { default = null } -variable "experiment_tmux_session_persistence" { - type = bool - description = "Whether to enable tmux session persistence across workspace restarts." - default = false -} - -variable "experiment_tmux_session_save_interval" { - type = string - description = "How often to save tmux sessions in minutes." - default = "15" -} variable "install_agentapi" { type = bool @@ -180,24 +163,6 @@ resource "coder_script" "claude_code" { 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 "${local.workdir}" ]; then echo "Warning: The specified folder '${local.workdir}' does not exist." echo "Creating the folder..." @@ -283,133 +248,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." + 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 - 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 - - tmux new-session -d -s agentapi-cc -c ${local.workdir} '~/.agentapi-start-command true; exec bash' - ~/.agentapi-wait-for-start-command - - if [ "${var.experiment_tmux_session_persistence}" = "true" ]; then - sleep 3 - fi - - 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 ${local.workdir} "agentapi attach; exec bash" - 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 [ "${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 + 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 - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 + 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 - screen -U -dmS agentapi-cc bash -c ' - cd ${local.workdir} - # setting the first argument will make claude use the prompt - ~/.agentapi-start-command true - exec bash - ' - ~/.agentapi-wait-for-start-command + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 - screen -U -dmS claude-code bash -c ' - cd ${local.workdir} - agentapi attach - 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 + screen -U -dmS agentapi-cc bash -c ' + cd ${local.workdir} + # setting the first argument will make claude use the prompt + ~/.agentapi-start-command true + exec bash + ' + ~/.agentapi-wait-for-start-command EOT run_on_start = true } @@ -440,40 +315,16 @@ 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 agentapi-cc 2>/dev/null; then + if ! screen -list | grep -q "agentapi-cc"; then + screen -S agentapi-cc bash -c ' + cd ${local.workdir} # start agentapi without claude using the prompt (no argument) - tmux new-session -d -s agentapi-cc -c ${local.workdir} '~/.agentapi-start-command; exec bash' - ~/.agentapi-wait-for-start-command - fi - - 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" - 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 ${local.workdir} "agentapi attach; exec bash" - fi - elif [ "${var.experiment_use_screen}" = "true" ]; then - if ! screen -list | grep -q "agentapi-cc"; then - screen -S agentapi-cc bash -c ' - cd ${local.workdir} - # start agentapi without claude using the prompt (no argument) - ~/.agentapi-start-command - exec bash - ' - fi - 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 'agentapi attach; exec bash' - fi - else - cd ${local.workdir} - agentapi attach + ~/.agentapi-start-command + exec bash + ' fi + + agentapi attach EOT icon = var.icon order = var.order From 78807e342539b99a3fb595eae17b592198442cbd Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 26 Jun 2025 18:35:29 +0200 Subject: [PATCH 3/3] refactor --- .../coder/modules/claude-code/main.test.ts | 72 ++++++++++ registry/coder/modules/claude-code/main.tf | 129 +++++------------- .../claude-code/scripts/agentapi-start.sh | 63 +++++++++ .../scripts/agentapi-wait-for-start.sh | 31 +++++ .../scripts/remove-last-session-id.js | 40 ++++++ test/test.ts | 52 +++++-- 6 files changed, 278 insertions(+), 109 deletions(-) create mode 100644 registry/coder/modules/claude-code/main.test.ts create mode 100644 registry/coder/modules/claude-code/scripts/agentapi-start.sh create mode 100644 registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh create mode 100644 registry/coder/modules/claude-code/scripts/remove-last-session-id.js 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 0000000..3a618c1 --- /dev/null +++ b/registry/coder/modules/claude-code/main.test.ts @@ -0,0 +1,72 @@ +import { test, afterEach } from "bun:test"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + 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 }; +}; + +test("claude-code", async () => { + const { id, coderScript } = await setupContainer({ + vars: { + experiment_report_tasks: "true", + agentapi_version: "preview", + }, + }); + await writeCoder(id, "#!/bin/bash\necho ok"); + await writeFileContainer(id, "/home/coder/script.sh", coderScript.script, { + user: "coder", + }); + await execContainer(id, ["bash", "-c", "chmod 755 /home/coder/script.sh"], ["--user", "root"]); + const resp = await execContainer(id, ["bash", "-c", "sudo /home/coder/script.sh"]); + console.log(resp.stdout); + console.log(resp.stderr); + console.log(resp.exitCode); + + // sleep for 200 seconds + await new Promise((resolve) => setTimeout(resolve, 200000)); +}); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 494e632..7651b6d 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -97,57 +97,9 @@ locals { 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_command = <<-EOT - #!/bin/bash - set -e - - # if the first argument is not empty, start claude with the prompt - if [ -n "$1" ]; then - prompt="$(cat ~/.claude-code-prompt)" - cp ~/.claude-code-prompt /tmp/claude-code-prompt - else - rm -f /tmp/claude-code-prompt - fi - - # We need to check if there's a session to use --continue. If there's no session, - # using this flag would cause claude to exit with an error. - # warning: this is a hack and will break if claude changes the format of the .claude.json file. - # Also, this solution is not ideal: a user has to quit claude in order for the session id to appear - # in .claude.json. If they just restart the workspace, the session id will not be available. - continue_flag="" - if grep -q '"lastSessionId":' ~/.claude.json; then - echo "Found a Claude Code session to continue." - continue_flag="--continue" - else - echo "No Claude Code session to continue." - fi - - # 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. - prompt_subshell='"$(cat /tmp/claude-code-prompt)"' - agentapi server --term-width 67 --term-height 1190 -- bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" - EOT - agentapi_wait_for_start_command = <<-EOT - #!/bin/bash - set -o errexit - set -o pipefail - - echo "Waiting for agentapi server to start on port 3284..." - for i in $(seq 1 15); do - if lsof -i :3284 | grep -q 'LISTEN'; then - echo "agentapi server started on port 3284." - break - fi - echo "Waiting... ($i/15)" - sleep 1 - done - if ! lsof -i :3284 | grep -q 'LISTEN'; then - echo "Error: agentapi server did not start on port 3284 after 15 seconds." - exit 1 - fi - EOT - agentapi_start_command_base64 = base64encode(local.agentapi_start_command) - agentapi_wait_for_start_command_base64 = base64encode(local.agentapi_wait_for_start_command) + 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")) } # Install and Initialize Claude Code @@ -158,6 +110,7 @@ resource "coder_script" "claude_code" { script = <<-EOT #!/bin/bash set -e + set -x command_exists() { command -v "$1" >/dev/null 2>&1 @@ -207,6 +160,11 @@ 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..." @@ -219,22 +177,35 @@ resource "coder_script" "claude_code" { echo "Error: Unsupported architecture: $arch" exit 1 fi - wget "https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name" - chmod +x "$binary_name" - sudo mv "$binary_name" /usr/local/bin/agentapi + 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" > ~/.claude-code-prompt + echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > "$module_path/prompt.txt" - echo -n "${local.agentapi_start_command_base64}" | base64 -d > ~/.agentapi-start-command - chmod +x ~/.agentapi-start-command - echo -n "${local.agentapi_wait_for_start_command_base64}" | base64 -d > ~/.agentapi-wait-for-start-command - chmod +x ~/.agentapi-wait-for-start-command + 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..." @@ -253,38 +224,11 @@ resource "coder_script" "claude_code" { exit 1 fi - 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 agentapi-cc bash -c ' - cd ${local.workdir} - # setting the first argument will make claude use the prompt - ~/.agentapi-start-command true - exec bash - ' - ~/.agentapi-wait-for-start-command + 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 } @@ -315,15 +259,6 @@ resource "coder_app" "claude_code" { export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - if ! screen -list | grep -q "agentapi-cc"; then - screen -S agentapi-cc bash -c ' - cd ${local.workdir} - # start agentapi without claude using the prompt (no argument) - ~/.agentapi-start-command - exec bash - ' - fi - agentapi attach EOT icon = var.icon 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 0000000..c66b7f3 --- /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 0000000..b23fc55 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh @@ -0,0 +1,31 @@ +#!/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 15); do + for j in $(seq 1 3); do + if curl -fs -o /dev/null "http://localhost:3284/status"; then + echo "agentapi response received ($j/3)" + sleep 1 + else + echo "agentapi server not responding ($i/15)" + sleep 1 + 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 0000000..0b66edf --- /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/test/test.ts b/test/test.ts index 4f41318..8138003 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[]; @@ -72,22 +87,20 @@ export const execContainer = async ( stderr: string; stdout: string; }> => { - const proc = spawn(["docker", "exec", ...(args ?? []), id, ...cmd], { - stderr: "pipe", - stdout: "pipe", - }); - const [stderr, stdout] = await Promise.all([ - readableStreamToText(proc.stderr), - readableStreamToText(proc.stdout), - ]); + const proc = spawn(["docker", "exec", ...(args ?? []), id, ...cmd]); + // const [stderr, stdout] = await Promise.all([ + // readableStreamToText(proc.stderr ?? new ReadableStream()), + // readableStreamToText(proc.stdout ?? new ReadableStream()), + // ]); const exitCode = await proc.exited; return { exitCode, - stderr, - stdout, + stderr: "", + stdout: "", }; }; + type JsonValue = | string | number @@ -279,10 +292,25 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => { }; export const writeCoder = async (id: string, script: string) => { + const scriptBase64 = Buffer.from(script).toString("base64"); const exec = await execContainer(id, [ "sh", "-c", - `echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`, - ]); + `echo '${scriptBase64}' | base64 -d > /usr/bin/coder && chmod 755 /usr/bin/coder`, + ], ["--user", "root"]); + if (exec.exitCode !== 0) { + throw new Error(`Failed to write coder script: ${exec.stderr}`); + } expect(exec.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); +}; \ No newline at end of file