From e31b5838499f01a7dc1dfec9a4244fdd88445a8c Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 14:12:26 -0600 Subject: [PATCH 01/12] feat(freeform): add opt-in Claude Code remote-control support Adds enable_claude_code and claude_code_skip_permissions coder_parameters that launch `claude --remote-control` in a named tmux session on startup, connectable from claude.ai/code or the Claude mobile app. Includes a claude-here helper to retarget the session to the current directory, and a terminal-tab coder_app fallback for first login/troubleshooting. Co-Authored-By: Claude Sonnet 5 --- freeform/template.tf | 66 ++++++++++++++++++++++++++++++ freeform/tests/validate.tftest.hcl | 50 ++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/freeform/template.tf b/freeform/template.tf index bba01e1..97eb23e 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -95,6 +95,28 @@ data "coder_parameter" "vscode_extensions" { } } +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = "Launch `claude --remote-control` in a named tmux session, controllable from claude.ai/code or the Claude mobile app. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = 10 +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = 11 +} + locals { workspace_home = "/home/coder" selected_extensions = jsondecode(data.coder_parameter.vscode_extensions.value) @@ -111,6 +133,16 @@ locals { ? [for s in split(",", local._project_names_raw) : trimspace(s) if trimspace(s) != ""] : [data.coder_workspace.me.name] ) + + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in tests defaults every parameter's value to "[]", which tobool() + # would reject outright. + claude_code_enabled = data.coder_parameter.enable_claude_code.value == "true" + claude_code_skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + claude_remote_cmd = join(" ", compact([ + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", + local.claude_code_skip_permissions ? "--dangerously-skip-permissions" : "", + ])) } variable "vscode_extensions" { @@ -402,6 +434,28 @@ BASHCOMP echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc fi + # Claude Code remote control: launch `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.claude_code_enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.claude_remote_cmd}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.claude_remote_cmd}" + + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' +#!/usr/bin/env bash +set -euo pipefail +tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" +CLAUDEHERE + chmod +x ~/.local/bin/claude-here + + if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + fi + fi + echo "" echo "=== Setup Complete ===" echo "" @@ -536,6 +590,18 @@ resource "coder_app" "adminer" { } } +# Claude Code remote control: terminal-tab fallback for first login/troubleshooting. +# The primary intended usage is connecting from claude.ai/code or the mobile +# app to the named tmux session started by the startup script, not this button. +resource "coder_app" "claude_code" { + count = local.claude_code_enabled ? 1 : 0 + agent_id = coder_agent.main.id + slug = "claude-code" + display_name = "Claude Code" + command = "tmux attach -t \"$CODER_WORKSPACE_NAME\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + order = 12 +} + resource "coder_script" "ddev_shutdown" { agent_id = coder_agent.main.id display_name = "Stop DDEV Projects" diff --git a/freeform/tests/validate.tftest.hcl b/freeform/tests/validate.tftest.hcl index a126552..b281abe 100644 --- a/freeform/tests/validate.tftest.hcl +++ b/freeform/tests/validate.tftest.hcl @@ -140,3 +140,53 @@ run "linuxbrew_volume_created" { error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" } } + +run "claude_code_disabled_by_default" { + command = plan + assert { + condition = length(coder_app.claude_code) == 0 + error_message = "coder_app.claude_code must not be created when enable_claude_code=false" + } +} + +run "claude_code_enabled" { + command = plan + override_data { + target = data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + assert { + condition = length(coder_app.claude_code) == 1 + error_message = "coder_app.claude_code must be created when enable_claude_code=true" + } + assert { + condition = strcontains(coder_agent.main.startup_script, "claude --remote-control") + error_message = "startup script must actually launch claude --remote-control" + } + assert { + condition = !strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag must not appear unless claude_code_skip_permissions is also set" + } +} + +run "claude_code_enabled_skip_permissions" { + command = plan + override_data { + target = data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + override_data { + target = data.coder_parameter.claude_code_skip_permissions + values = { + value = "true" + } + } + assert { + condition = strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag should appear in the generated command when claude_code_skip_permissions=true" + } +} From 329a47d1283a464f3090a530f8614fd2f51071b7 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 14:36:54 -0600 Subject: [PATCH 02/12] fix(freeform): pin claude-here/coder_app tmux targets to window 0 An untargeted "tmux -t $CODER_WORKSPACE_NAME" resolves to the session's currently active window, not window 0 specifically. Verified on staging: with a second tmux window present, claude-here silently respawned the wrong pane instead of the actual Claude Code session. Pin all three tmux references (claude-here's respawn-pane and the coder_app attach command) to window 0 so they always target the session's original window. Co-Authored-By: Claude Sonnet 5 --- freeform/template.tf | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/freeform/template.tf b/freeform/template.tf index 97eb23e..061e052 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -442,11 +442,17 @@ BASHCOMP echo 'export CLAUDE_REMOTE_CMD="${local.claude_remote_cmd}"' >> ~/.bashrc export CLAUDE_REMOTE_CMD="${local.claude_remote_cmd}" + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. mkdir -p ~/.local/bin cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail -tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" +tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" CLAUDEHERE chmod +x ~/.local/bin/claude-here @@ -598,7 +604,7 @@ resource "coder_app" "claude_code" { agent_id = coder_agent.main.id slug = "claude-code" display_name = "Claude Code" - command = "tmux attach -t \"$CODER_WORKSPACE_NAME\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" order = 12 } From b06f64f3d413f96325463e8014b7cbaecab1e70c Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 21:29:59 +0000 Subject: [PATCH 03/12] fix: don't write ~/.bashrc customizations through symlinks into dotfiles repos If ~/.bashrc is a symlink resolving outside $HOME (e.g. a dotfiles repo checked out under the home directory), every subsequent echo/sed/cat append in these startup scripts writes straight through it into whatever it points at. On at least one real workspace this silently edited tracked files inside a developer's dotfiles repo instead of the workspace's own local shell config. Before the skeleton-copy/personalization steps run, detect that case and replace the symlink with a real ~/.bashrc that sources the original target, so later appends land in a local file instead of someone's version-controlled dotfiles. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RrCPvPyTZYoEXzCoyvGXGq --- drupal-contrib/template.tf | 21 +++++++++++++++++++++ drupal-core/template.tf | 21 +++++++++++++++++++++ freeform/template.tf | 21 +++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/drupal-contrib/template.tf b/drupal-contrib/template.tf index 8891590..3ae0cfa 100644 --- a/drupal-contrib/template.tf +++ b/drupal-contrib/template.tf @@ -315,6 +315,27 @@ resource "coder_agent" "main" { sudo chown coder:coder /home/coder sudo chown -R coder:coder /home/linuxbrew + # If ~/.bashrc is already a symlink resolving outside $HOME (e.g. a + # dotfiles repo checked out under $HOME), replace it with a real file + # that sources the original target before the exports below get + # appended. Every append/sed step further down writes straight through + # symlinks, so a live symlink here would silently edit tracked files in + # a developer's dotfiles repo instead of this workspace's local shell + # config -- that happened once already. + if [ -L "/home/coder/.bashrc" ]; then + _bashrc_target=$(readlink -f "/home/coder/.bashrc" 2>/dev/null || true) + case "$_bashrc_target" in + /home/coder/*) ;; # resolves inside $HOME, safe to write through + *) + echo "~/.bashrc is a symlink to $_bashrc_target (outside \$HOME); replacing with a local file that sources it" + rm -f "/home/coder/.bashrc" + if [ -n "$_bashrc_target" ]; then + echo "[ -r \"$_bashrc_target\" ] && source \"$_bashrc_target\"" > "/home/coder/.bashrc" + fi + ;; + esac + fi + if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." cp -rT /etc/skel/. /home/coder/ diff --git a/drupal-core/template.tf b/drupal-core/template.tf index 0dbf358..c5893fd 100644 --- a/drupal-core/template.tf +++ b/drupal-core/template.tf @@ -357,6 +357,27 @@ resource "coder_agent" "main" { sudo chown coder:coder /home/coder sudo chown -R coder:coder /home/linuxbrew + # If ~/.bashrc is already a symlink resolving outside $HOME (e.g. a + # dotfiles repo checked out under $HOME), replace it with a real file + # that sources the original target before the exports below get + # appended. Every append/sed step further down writes straight through + # symlinks, so a live symlink here would silently edit tracked files in + # a developer's dotfiles repo instead of this workspace's local shell + # config -- that happened once already. + if [ -L "/home/coder/.bashrc" ]; then + _bashrc_target=$(readlink -f "/home/coder/.bashrc" 2>/dev/null || true) + case "$_bashrc_target" in + /home/coder/*) ;; # resolves inside $HOME, safe to write through + *) + echo "~/.bashrc is a symlink to $_bashrc_target (outside \$HOME); replacing with a local file that sources it" + rm -f "/home/coder/.bashrc" + if [ -n "$_bashrc_target" ]; then + echo "[ -r \"$_bashrc_target\" ] && source \"$_bashrc_target\"" > "/home/coder/.bashrc" + fi + ;; + esac + fi + # Copy defaults if empty (first run) if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." diff --git a/freeform/template.tf b/freeform/template.tf index 061e052..4df5dce 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -240,6 +240,27 @@ resource "coder_agent" "main" { sudo chown coder:coder /home/coder sudo chown -R coder:coder /home/linuxbrew + # If ~/.bashrc is already a symlink resolving outside $HOME (e.g. a + # dotfiles repo checked out under $HOME), replace it with a real file + # that sources the original target before the exports below get + # appended. Every append/sed step further down writes straight through + # symlinks, so a live symlink here would silently edit tracked files in + # a developer's dotfiles repo instead of this workspace's local shell + # config -- that happened once already. + if [ -L "/home/coder/.bashrc" ]; then + _bashrc_target=$(readlink -f "/home/coder/.bashrc" 2>/dev/null || true) + case "$_bashrc_target" in + /home/coder/*) ;; # resolves inside $HOME, safe to write through + *) + echo "~/.bashrc is a symlink to $_bashrc_target (outside \$HOME); replacing with a local file that sources it" + rm -f "/home/coder/.bashrc" + if [ -n "$_bashrc_target" ]; then + echo "[ -r \"$_bashrc_target\" ] && source \"$_bashrc_target\"" > "/home/coder/.bashrc" + fi + ;; + esac + fi + if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." cp -rT /etc/skel/. /home/coder/ From 76f4d79c5ac5f5ae7ef012a066bd0b545df29bc8 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 16:12:44 -0600 Subject: [PATCH 04/12] docs(freeform): explain Claude Code remote-control usage in the parameter description Rewrite enable_claude_code's description to lead with what the user needs to do (first-use login/trust steps, using claude-here to work in a project directory) rather than the underlying tmux/CLI mechanism, since this text is what actually shows up in the workspace creation form and Settings -> Parameters page. Also notes the ~10-minute offline reconnect limit that was called out as a documented tradeoff but never made it into the text. Co-Authored-By: Claude Sonnet 5 --- freeform/template.tf | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/freeform/template.tf b/freeform/template.tf index 4df5dce..2f04ada 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -98,12 +98,21 @@ data "coder_parameter" "vscode_extensions" { data "coder_parameter" "enable_claude_code" { name = "enable_claude_code" display_name = "Enable Claude Code remote control" - description = "Launch `claude --remote-control` in a named tmux session, controllable from claude.ai/code or the Claude mobile app. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway." - type = "bool" - form_type = "switch" - default = "false" - mutable = true - order = 10 + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = 10 } data "coder_parameter" "claude_code_skip_permissions" { @@ -455,7 +464,7 @@ BASHCOMP echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc fi - # Claude Code remote control: launch `claude --remote-control` in a named + # Claude Code remote control: this launches `claude --remote-control` in a named # tmux session so it's connectable from claude.ai/code or the mobile app. CLAUDE_CODE_ENABLED="${local.claude_code_enabled}" if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then From 69b94250e6d717c284517624858a24951b2565af Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 16:24:39 -0600 Subject: [PATCH 05/12] refactor: extract Claude Code remote-control into a shared module for all templates Move the enable_claude_code / claude_code_skip_permissions parameters, the computed remote-control command, the claude-here startup-script snippet, and the fallback coder_app into modules/claude-remote-control/, then wire it into drupal-core and drupal-contrib in addition to freeform, instead of copy-pasting the feature into each template.tf. Each template just does: module "claude_remote_control" { source = "../modules/claude-remote-control" agent_id = coder_agent.main.id } and splices ${module.claude_remote_control.startup_script} into its own startup script, at the point after its npm-global setup. Adds the same 3 test runs (disabled-by-default, enabled, enabled with skip-permissions) to drupal-core and drupal-contrib's test suites, mirroring freeform's existing coverage. Terraform test assertions can't reach into a child module's internal resources directly, so the module also exposes an `app_count` output for the enabled/disabled assertions. Co-Authored-By: Claude Sonnet 5 --- drupal-contrib/template.tf | 7 ++ drupal-contrib/tests/validate.tftest.hcl | 50 +++++++++++++ drupal-core/template.tf | 8 +- drupal-core/tests/validate.tftest.hcl | 59 +++++++++++++++ freeform/template.tf | 86 ++-------------------- freeform/tests/validate.tftest.hcl | 14 ++-- modules/claude-remote-control/main.tf | 54 ++++++++++++++ modules/claude-remote-control/outputs.tf | 43 +++++++++++ modules/claude-remote-control/variables.tf | 16 ++++ modules/claude-remote-control/versions.tf | 7 ++ 10 files changed, 256 insertions(+), 88 deletions(-) create mode 100644 modules/claude-remote-control/main.tf create mode 100644 modules/claude-remote-control/outputs.tf create mode 100644 modules/claude-remote-control/variables.tf create mode 100644 modules/claude-remote-control/versions.tf diff --git a/drupal-contrib/template.tf b/drupal-contrib/template.tf index 3ae0cfa..27728eb 100644 --- a/drupal-contrib/template.tf +++ b/drupal-contrib/template.tf @@ -1019,6 +1019,8 @@ BASHPROFILE_WELCOME printf '\n# Source system-wide settings (bash_completion etc.) for login shells\nif [ -f /etc/bash.bashrc ]; then\n . /etc/bash.bashrc\nfi\n' >> ~/.bash_profile fi + ${module.claude_remote_control.startup_script} + # ========================================== # Timing summary and final status # ========================================== @@ -1115,6 +1117,11 @@ module "vscode-web" { extensions = local.selected_extensions } +module "claude_remote_control" { + source = "../modules/claude-remote-control" + agent_id = coder_agent.main.id +} + resource "coder_app" "ddev-web" { agent_id = coder_agent.main.id slug = "ddev-web" diff --git a/drupal-contrib/tests/validate.tftest.hcl b/drupal-contrib/tests/validate.tftest.hcl index 5950625..9bc20b6 100644 --- a/drupal-contrib/tests/validate.tftest.hcl +++ b/drupal-contrib/tests/validate.tftest.hcl @@ -86,3 +86,53 @@ run "linuxbrew_volume_created" { error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" } } + +run "claude_code_disabled_by_default" { + command = plan + assert { + condition = module.claude_remote_control.app_count == 0 + error_message = "module.claude_remote_control's coder_app.claude_code must not be created when enable_claude_code=false" + } +} + +run "claude_code_enabled" { + command = plan + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + assert { + condition = module.claude_remote_control.app_count == 1 + error_message = "module.claude_remote_control's coder_app.claude_code must be created when enable_claude_code=true" + } + assert { + condition = strcontains(coder_agent.main.startup_script, "claude --remote-control") + error_message = "startup script must actually launch claude --remote-control" + } + assert { + condition = !strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag must not appear unless claude_code_skip_permissions is also set" + } +} + +run "claude_code_enabled_skip_permissions" { + command = plan + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + override_data { + target = module.claude_remote_control.data.coder_parameter.claude_code_skip_permissions + values = { + value = "true" + } + } + assert { + condition = strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag should appear in the generated command when claude_code_skip_permissions=true" + } +} diff --git a/drupal-core/template.tf b/drupal-core/template.tf index c5893fd..b1a4d91 100644 --- a/drupal-core/template.tf +++ b/drupal-core/template.tf @@ -1309,7 +1309,8 @@ BASHCOMP sudo ln -sf ~/.npm-global/bin/task-master-ai /usr/local/bin/task-master-ai 2>/dev/null || true fi fi - + + ${module.claude_remote_control.startup_script} @@ -1405,6 +1406,11 @@ module "vscode-web" { extensions = local.selected_extensions } +module "claude_remote_control" { + source = "../modules/claude-remote-control" + agent_id = coder_agent.main.id +} + # DDEV Web Server (HTTP) - appears when DDEV project is running # Uses subdomain routing for unique URLs per workspace resource "coder_app" "ddev-web" { diff --git a/drupal-core/tests/validate.tftest.hcl b/drupal-core/tests/validate.tftest.hcl index 27c38aa..c2b4316 100644 --- a/drupal-core/tests/validate.tftest.hcl +++ b/drupal-core/tests/validate.tftest.hcl @@ -93,3 +93,62 @@ run "linuxbrew_volume_created" { error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" } } + +run "claude_code_disabled_by_default" { + command = plan + variables { + cache_path = "/tmp/mock-cache" + } + assert { + condition = module.claude_remote_control.app_count == 0 + error_message = "module.claude_remote_control's coder_app.claude_code must not be created when enable_claude_code=false" + } +} + +run "claude_code_enabled" { + command = plan + variables { + cache_path = "/tmp/mock-cache" + } + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + assert { + condition = module.claude_remote_control.app_count == 1 + error_message = "module.claude_remote_control's coder_app.claude_code must be created when enable_claude_code=true" + } + assert { + condition = strcontains(coder_agent.main.startup_script, "claude --remote-control") + error_message = "startup script must actually launch claude --remote-control" + } + assert { + condition = !strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag must not appear unless claude_code_skip_permissions is also set" + } +} + +run "claude_code_enabled_skip_permissions" { + command = plan + variables { + cache_path = "/tmp/mock-cache" + } + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + override_data { + target = module.claude_remote_control.data.coder_parameter.claude_code_skip_permissions + values = { + value = "true" + } + } + assert { + condition = strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag should appear in the generated command when claude_code_skip_permissions=true" + } +} diff --git a/freeform/template.tf b/freeform/template.tf index 2f04ada..95233cd 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -95,37 +95,6 @@ data "coder_parameter" "vscode_extensions" { } } -data "coder_parameter" "enable_claude_code" { - name = "enable_claude_code" - display_name = "Enable Claude Code remote control" - description = <<-EOT - Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. - - **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. - - Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. - EOT - - type = "bool" - form_type = "switch" - default = "false" - mutable = true - order = 10 -} - -data "coder_parameter" "claude_code_skip_permissions" { - name = "claude_code_skip_permissions" - display_name = "Claude Code: skip permission prompts" - description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." - type = "bool" - form_type = "switch" - default = "false" - mutable = true - order = 11 -} - locals { workspace_home = "/home/coder" selected_extensions = jsondecode(data.coder_parameter.vscode_extensions.value) @@ -142,16 +111,6 @@ locals { ? [for s in split(",", local._project_names_raw) : trimspace(s) if trimspace(s) != ""] : [data.coder_workspace.me.name] ) - - # Deliberately compared as strings, not tobool(): the blanket coder_parameter - # mock in tests defaults every parameter's value to "[]", which tobool() - # would reject outright. - claude_code_enabled = data.coder_parameter.enable_claude_code.value == "true" - claude_code_skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" - claude_remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", - local.claude_code_skip_permissions ? "--dangerously-skip-permissions" : "", - ])) } variable "vscode_extensions" { @@ -464,33 +423,7 @@ BASHCOMP echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc fi - # Claude Code remote control: this launches `claude --remote-control` in a named - # tmux session so it's connectable from claude.ai/code or the mobile app. - CLAUDE_CODE_ENABLED="${local.claude_code_enabled}" - if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then - sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true - echo 'export CLAUDE_REMOTE_CMD="${local.claude_remote_cmd}"' >> ~/.bashrc - export CLAUDE_REMOTE_CMD="${local.claude_remote_cmd}" - - # Target window 0 explicitly, not just the session: an untargeted - # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current - # *active* window, which silently becomes some other window if one - # was ever created (e.g. by the user running tmux themselves) and - # left focused - respawning the wrong pane instead of the intended - # Claude Code session. - mkdir -p ~/.local/bin - cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' -#!/usr/bin/env bash -set -euo pipefail -tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" -CLAUDEHERE - chmod +x ~/.local/bin/claude-here - - if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" - echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" - fi - fi + ${module.claude_remote_control.startup_script} echo "" echo "=== Setup Complete ===" @@ -554,6 +487,11 @@ module "vscode-web" { extensions = local.selected_extensions } +module "claude_remote_control" { + source = "../modules/claude-remote-control" + agent_id = coder_agent.main.id +} + # One coder_app per project name. All route to ddev-router on port 8080. # ddev-router dispatches by Host header: {slug}--{workspace}--{owner}.{domain} # The DDEV project name must equal the slug for coder-routes to build the correct rule. @@ -626,18 +564,6 @@ resource "coder_app" "adminer" { } } -# Claude Code remote control: terminal-tab fallback for first login/troubleshooting. -# The primary intended usage is connecting from claude.ai/code or the mobile -# app to the named tmux session started by the startup script, not this button. -resource "coder_app" "claude_code" { - count = local.claude_code_enabled ? 1 : 0 - agent_id = coder_agent.main.id - slug = "claude-code" - display_name = "Claude Code" - command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" - order = 12 -} - resource "coder_script" "ddev_shutdown" { agent_id = coder_agent.main.id display_name = "Stop DDEV Projects" diff --git a/freeform/tests/validate.tftest.hcl b/freeform/tests/validate.tftest.hcl index b281abe..f0c84ab 100644 --- a/freeform/tests/validate.tftest.hcl +++ b/freeform/tests/validate.tftest.hcl @@ -144,22 +144,22 @@ run "linuxbrew_volume_created" { run "claude_code_disabled_by_default" { command = plan assert { - condition = length(coder_app.claude_code) == 0 - error_message = "coder_app.claude_code must not be created when enable_claude_code=false" + condition = module.claude_remote_control.app_count == 0 + error_message = "module.claude_remote_control's coder_app.claude_code must not be created when enable_claude_code=false" } } run "claude_code_enabled" { command = plan override_data { - target = data.coder_parameter.enable_claude_code + target = module.claude_remote_control.data.coder_parameter.enable_claude_code values = { value = "true" } } assert { - condition = length(coder_app.claude_code) == 1 - error_message = "coder_app.claude_code must be created when enable_claude_code=true" + condition = module.claude_remote_control.app_count == 1 + error_message = "module.claude_remote_control's coder_app.claude_code must be created when enable_claude_code=true" } assert { condition = strcontains(coder_agent.main.startup_script, "claude --remote-control") @@ -174,13 +174,13 @@ run "claude_code_enabled" { run "claude_code_enabled_skip_permissions" { command = plan override_data { - target = data.coder_parameter.enable_claude_code + target = module.claude_remote_control.data.coder_parameter.enable_claude_code values = { value = "true" } } override_data { - target = data.coder_parameter.claude_code_skip_permissions + target = module.claude_remote_control.data.coder_parameter.claude_code_skip_permissions values = { value = "true" } diff --git a/modules/claude-remote-control/main.tf b/modules/claude-remote-control/main.tf new file mode 100644 index 0000000..bdb3cba --- /dev/null +++ b/modules/claude-remote-control/main.tf @@ -0,0 +1,54 @@ +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order + 1 +} + +locals { + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in callers' tests defaults every parameter's value to "[]", which + # tobool() would reject outright. + enabled = data.coder_parameter.enable_claude_code.value == "true" + skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + remote_cmd = join(" ", compact([ + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", + local.skip_permissions ? "--dangerously-skip-permissions" : "", + ])) +} + +# Terminal-tab fallback for first login/troubleshooting. The primary intended +# usage is connecting from claude.ai/code or the mobile app to the named tmux +# session started by the caller's startup script, not this button. +resource "coder_app" "claude_code" { + count = local.enabled ? 1 : 0 + agent_id = var.agent_id + slug = "claude-code" + display_name = "Claude Code" + command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + order = var.app_order +} diff --git a/modules/claude-remote-control/outputs.tf b/modules/claude-remote-control/outputs.tf new file mode 100644 index 0000000..06ee469 --- /dev/null +++ b/modules/claude-remote-control/outputs.tf @@ -0,0 +1,43 @@ +output "enabled" { + description = "Whether Claude Code remote control is enabled" + value = local.enabled +} + +output "app_count" { + description = "Number of coder_app.claude_code instances created (0 or 1) - exposed for tests" + value = length(coder_app.claude_code) +} + +output "startup_script" { + description = "Bash snippet for the caller to append to its coder_agent startup_script, after other ~/.bashrc customizations." + value = <<-EOT + + # Claude Code remote control: this launches `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' + #!/usr/bin/env bash + set -euo pipefail + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" + CLAUDEHERE + chmod +x ~/.local/bin/claude-here + + if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + fi + fi + EOT +} diff --git a/modules/claude-remote-control/variables.tf b/modules/claude-remote-control/variables.tf new file mode 100644 index 0000000..ffc56ad --- /dev/null +++ b/modules/claude-remote-control/variables.tf @@ -0,0 +1,16 @@ +variable "agent_id" { + description = "ID of the coder_agent to attach the Claude Code fallback terminal app to" + type = string +} + +variable "parameter_order" { + description = "Base UI order for the two coder_parameters this module declares (uses this value and this value + 1)" + type = number + default = 10 +} + +variable "app_order" { + description = "UI order for the fallback terminal coder_app" + type = number + default = 12 +} diff --git a/modules/claude-remote-control/versions.tf b/modules/claude-remote-control/versions.tf new file mode 100644 index 0000000..394c7cb --- /dev/null +++ b/modules/claude-remote-control/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} From c5ec23b7de6a06642c58f5a17018aff7c2f25a58 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 16:28:51 -0600 Subject: [PATCH 06/12] fix(templates): vendor the claude-remote-control module into each template dir `coder templates push --directory ` only bundles that single directory, so the module's original `../modules/claude-remote-control` source (which worked fine locally and in CI, since both operate against a full git checkout) resolved to a path that simply didn't exist in what actually got uploaded to the Coder server - discovered when `make push-template-freeform` failed with "Unable to evaluate directory symlink: lstat ../modules: no such file or directory". Fix: vendor a copy of modules/claude-remote-control into each template directory and point each template's module source at the local ./modules/claude-remote-control copy instead. modules/claude-remote-control at the repo root stays the canonical copy to edit; `make sync-claude-module` (now also a prerequisite of validate, test-templates, and each push-template-* target) copies it into all three template directories so they can't drift out of sync. Co-Authored-By: Claude Sonnet 5 --- Makefile | 19 +++++-- .../modules/claude-remote-control/main.tf | 54 +++++++++++++++++++ .../modules/claude-remote-control/outputs.tf | 43 +++++++++++++++ .../claude-remote-control/variables.tf | 16 ++++++ .../modules/claude-remote-control/versions.tf | 7 +++ drupal-contrib/template.tf | 2 +- .../modules/claude-remote-control/main.tf | 54 +++++++++++++++++++ .../modules/claude-remote-control/outputs.tf | 43 +++++++++++++++ .../claude-remote-control/variables.tf | 16 ++++++ .../modules/claude-remote-control/versions.tf | 7 +++ drupal-core/template.tf | 2 +- .../modules/claude-remote-control/main.tf | 54 +++++++++++++++++++ .../modules/claude-remote-control/outputs.tf | 43 +++++++++++++++ .../claude-remote-control/variables.tf | 16 ++++++ .../modules/claude-remote-control/versions.tf | 7 +++ freeform/template.tf | 2 +- 16 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 drupal-contrib/modules/claude-remote-control/main.tf create mode 100644 drupal-contrib/modules/claude-remote-control/outputs.tf create mode 100644 drupal-contrib/modules/claude-remote-control/variables.tf create mode 100644 drupal-contrib/modules/claude-remote-control/versions.tf create mode 100644 drupal-core/modules/claude-remote-control/main.tf create mode 100644 drupal-core/modules/claude-remote-control/outputs.tf create mode 100644 drupal-core/modules/claude-remote-control/variables.tf create mode 100644 drupal-core/modules/claude-remote-control/versions.tf create mode 100644 freeform/modules/claude-remote-control/main.tf create mode 100644 freeform/modules/claude-remote-control/outputs.tf create mode 100644 freeform/modules/claude-remote-control/variables.tf create mode 100644 freeform/modules/claude-remote-control/versions.tf diff --git a/Makefile b/Makefile index d544682..6f1e46f 100644 --- a/Makefile +++ b/Makefile @@ -103,8 +103,17 @@ test: ## Test the built image by running it docker run --rm $(IMAGE_TAG) node --version @echo "Test complete" +.PHONY: sync-claude-module +sync-claude-module: ## Vendor modules/claude-remote-control into each template dir (coder templates push only bundles the single --directory given to it, so a shared "../modules" reference doesn't survive the push; each template needs its own copy) + @for t in $(TEMPLATES); do \ + rm -rf $$t/modules/claude-remote-control; \ + mkdir -p $$t/modules; \ + cp -r modules/claude-remote-control $$t/modules/claude-remote-control; \ + done + @echo "Synced modules/claude-remote-control into: $(TEMPLATES)" + .PHONY: validate -validate: ## Validate all Terraform templates (requires terraform in PATH) +validate: sync-claude-module ## Validate all Terraform templates (requires terraform in PATH) @for t in $(TEMPLATES); do \ echo "--- Validating $$t ---"; \ (cd $$t && terraform init -backend=false -input=false -no-color && terraform validate -no-color) || exit 1; \ @@ -116,7 +125,7 @@ fmt-check: ## Check Terraform formatting across all templates terraform fmt -check -recursive .PHONY: test-templates -test-templates: ## Run Terraform mock unit tests for all templates (requires terraform in PATH) +test-templates: sync-claude-module ## Run Terraform mock unit tests for all templates (requires terraform in PATH) @for t in $(TEMPLATES); do \ echo "--- Testing $$t ---"; \ (cd $$t && terraform test) || exit 1; \ @@ -161,15 +170,15 @@ info: ## Show image and template information # --- Template push targets --- .PHONY: push-template-drupal-core -push-template-drupal-core: ## Push drupal-core template to Coder +push-template-drupal-core: sync-claude-module ## Push drupal-core template to Coder $(call push_template,drupal-core) .PHONY: push-template-drupal-contrib -push-template-drupal-contrib: ## Push drupal-contrib template to Coder +push-template-drupal-contrib: sync-claude-module ## Push drupal-contrib template to Coder $(call push_template,drupal-contrib) .PHONY: push-template-freeform -push-template-freeform: ## Push freeform template to Coder +push-template-freeform: sync-claude-module ## Push freeform template to Coder $(call push_template,freeform) .PHONY: push-all-templates diff --git a/drupal-contrib/modules/claude-remote-control/main.tf b/drupal-contrib/modules/claude-remote-control/main.tf new file mode 100644 index 0000000..bdb3cba --- /dev/null +++ b/drupal-contrib/modules/claude-remote-control/main.tf @@ -0,0 +1,54 @@ +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order + 1 +} + +locals { + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in callers' tests defaults every parameter's value to "[]", which + # tobool() would reject outright. + enabled = data.coder_parameter.enable_claude_code.value == "true" + skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + remote_cmd = join(" ", compact([ + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", + local.skip_permissions ? "--dangerously-skip-permissions" : "", + ])) +} + +# Terminal-tab fallback for first login/troubleshooting. The primary intended +# usage is connecting from claude.ai/code or the mobile app to the named tmux +# session started by the caller's startup script, not this button. +resource "coder_app" "claude_code" { + count = local.enabled ? 1 : 0 + agent_id = var.agent_id + slug = "claude-code" + display_name = "Claude Code" + command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + order = var.app_order +} diff --git a/drupal-contrib/modules/claude-remote-control/outputs.tf b/drupal-contrib/modules/claude-remote-control/outputs.tf new file mode 100644 index 0000000..06ee469 --- /dev/null +++ b/drupal-contrib/modules/claude-remote-control/outputs.tf @@ -0,0 +1,43 @@ +output "enabled" { + description = "Whether Claude Code remote control is enabled" + value = local.enabled +} + +output "app_count" { + description = "Number of coder_app.claude_code instances created (0 or 1) - exposed for tests" + value = length(coder_app.claude_code) +} + +output "startup_script" { + description = "Bash snippet for the caller to append to its coder_agent startup_script, after other ~/.bashrc customizations." + value = <<-EOT + + # Claude Code remote control: this launches `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' + #!/usr/bin/env bash + set -euo pipefail + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" + CLAUDEHERE + chmod +x ~/.local/bin/claude-here + + if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + fi + fi + EOT +} diff --git a/drupal-contrib/modules/claude-remote-control/variables.tf b/drupal-contrib/modules/claude-remote-control/variables.tf new file mode 100644 index 0000000..ffc56ad --- /dev/null +++ b/drupal-contrib/modules/claude-remote-control/variables.tf @@ -0,0 +1,16 @@ +variable "agent_id" { + description = "ID of the coder_agent to attach the Claude Code fallback terminal app to" + type = string +} + +variable "parameter_order" { + description = "Base UI order for the two coder_parameters this module declares (uses this value and this value + 1)" + type = number + default = 10 +} + +variable "app_order" { + description = "UI order for the fallback terminal coder_app" + type = number + default = 12 +} diff --git a/drupal-contrib/modules/claude-remote-control/versions.tf b/drupal-contrib/modules/claude-remote-control/versions.tf new file mode 100644 index 0000000..394c7cb --- /dev/null +++ b/drupal-contrib/modules/claude-remote-control/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} diff --git a/drupal-contrib/template.tf b/drupal-contrib/template.tf index 27728eb..3d78900 100644 --- a/drupal-contrib/template.tf +++ b/drupal-contrib/template.tf @@ -1118,7 +1118,7 @@ module "vscode-web" { } module "claude_remote_control" { - source = "../modules/claude-remote-control" + source = "./modules/claude-remote-control" agent_id = coder_agent.main.id } diff --git a/drupal-core/modules/claude-remote-control/main.tf b/drupal-core/modules/claude-remote-control/main.tf new file mode 100644 index 0000000..bdb3cba --- /dev/null +++ b/drupal-core/modules/claude-remote-control/main.tf @@ -0,0 +1,54 @@ +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order + 1 +} + +locals { + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in callers' tests defaults every parameter's value to "[]", which + # tobool() would reject outright. + enabled = data.coder_parameter.enable_claude_code.value == "true" + skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + remote_cmd = join(" ", compact([ + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", + local.skip_permissions ? "--dangerously-skip-permissions" : "", + ])) +} + +# Terminal-tab fallback for first login/troubleshooting. The primary intended +# usage is connecting from claude.ai/code or the mobile app to the named tmux +# session started by the caller's startup script, not this button. +resource "coder_app" "claude_code" { + count = local.enabled ? 1 : 0 + agent_id = var.agent_id + slug = "claude-code" + display_name = "Claude Code" + command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + order = var.app_order +} diff --git a/drupal-core/modules/claude-remote-control/outputs.tf b/drupal-core/modules/claude-remote-control/outputs.tf new file mode 100644 index 0000000..06ee469 --- /dev/null +++ b/drupal-core/modules/claude-remote-control/outputs.tf @@ -0,0 +1,43 @@ +output "enabled" { + description = "Whether Claude Code remote control is enabled" + value = local.enabled +} + +output "app_count" { + description = "Number of coder_app.claude_code instances created (0 or 1) - exposed for tests" + value = length(coder_app.claude_code) +} + +output "startup_script" { + description = "Bash snippet for the caller to append to its coder_agent startup_script, after other ~/.bashrc customizations." + value = <<-EOT + + # Claude Code remote control: this launches `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' + #!/usr/bin/env bash + set -euo pipefail + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" + CLAUDEHERE + chmod +x ~/.local/bin/claude-here + + if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + fi + fi + EOT +} diff --git a/drupal-core/modules/claude-remote-control/variables.tf b/drupal-core/modules/claude-remote-control/variables.tf new file mode 100644 index 0000000..ffc56ad --- /dev/null +++ b/drupal-core/modules/claude-remote-control/variables.tf @@ -0,0 +1,16 @@ +variable "agent_id" { + description = "ID of the coder_agent to attach the Claude Code fallback terminal app to" + type = string +} + +variable "parameter_order" { + description = "Base UI order for the two coder_parameters this module declares (uses this value and this value + 1)" + type = number + default = 10 +} + +variable "app_order" { + description = "UI order for the fallback terminal coder_app" + type = number + default = 12 +} diff --git a/drupal-core/modules/claude-remote-control/versions.tf b/drupal-core/modules/claude-remote-control/versions.tf new file mode 100644 index 0000000..394c7cb --- /dev/null +++ b/drupal-core/modules/claude-remote-control/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} diff --git a/drupal-core/template.tf b/drupal-core/template.tf index b1a4d91..003e893 100644 --- a/drupal-core/template.tf +++ b/drupal-core/template.tf @@ -1407,7 +1407,7 @@ module "vscode-web" { } module "claude_remote_control" { - source = "../modules/claude-remote-control" + source = "./modules/claude-remote-control" agent_id = coder_agent.main.id } diff --git a/freeform/modules/claude-remote-control/main.tf b/freeform/modules/claude-remote-control/main.tf new file mode 100644 index 0000000..bdb3cba --- /dev/null +++ b/freeform/modules/claude-remote-control/main.tf @@ -0,0 +1,54 @@ +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order + 1 +} + +locals { + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in callers' tests defaults every parameter's value to "[]", which + # tobool() would reject outright. + enabled = data.coder_parameter.enable_claude_code.value == "true" + skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + remote_cmd = join(" ", compact([ + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", + local.skip_permissions ? "--dangerously-skip-permissions" : "", + ])) +} + +# Terminal-tab fallback for first login/troubleshooting. The primary intended +# usage is connecting from claude.ai/code or the mobile app to the named tmux +# session started by the caller's startup script, not this button. +resource "coder_app" "claude_code" { + count = local.enabled ? 1 : 0 + agent_id = var.agent_id + slug = "claude-code" + display_name = "Claude Code" + command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + order = var.app_order +} diff --git a/freeform/modules/claude-remote-control/outputs.tf b/freeform/modules/claude-remote-control/outputs.tf new file mode 100644 index 0000000..06ee469 --- /dev/null +++ b/freeform/modules/claude-remote-control/outputs.tf @@ -0,0 +1,43 @@ +output "enabled" { + description = "Whether Claude Code remote control is enabled" + value = local.enabled +} + +output "app_count" { + description = "Number of coder_app.claude_code instances created (0 or 1) - exposed for tests" + value = length(coder_app.claude_code) +} + +output "startup_script" { + description = "Bash snippet for the caller to append to its coder_agent startup_script, after other ~/.bashrc customizations." + value = <<-EOT + + # Claude Code remote control: this launches `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' + #!/usr/bin/env bash + set -euo pipefail + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" + CLAUDEHERE + chmod +x ~/.local/bin/claude-here + + if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + fi + fi + EOT +} diff --git a/freeform/modules/claude-remote-control/variables.tf b/freeform/modules/claude-remote-control/variables.tf new file mode 100644 index 0000000..ffc56ad --- /dev/null +++ b/freeform/modules/claude-remote-control/variables.tf @@ -0,0 +1,16 @@ +variable "agent_id" { + description = "ID of the coder_agent to attach the Claude Code fallback terminal app to" + type = string +} + +variable "parameter_order" { + description = "Base UI order for the two coder_parameters this module declares (uses this value and this value + 1)" + type = number + default = 10 +} + +variable "app_order" { + description = "UI order for the fallback terminal coder_app" + type = number + default = 12 +} diff --git a/freeform/modules/claude-remote-control/versions.tf b/freeform/modules/claude-remote-control/versions.tf new file mode 100644 index 0000000..394c7cb --- /dev/null +++ b/freeform/modules/claude-remote-control/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} diff --git a/freeform/template.tf b/freeform/template.tf index 95233cd..2ce77ce 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -488,7 +488,7 @@ module "vscode-web" { } module "claude_remote_control" { - source = "../modules/claude-remote-control" + source = "./modules/claude-remote-control" agent_id = coder_agent.main.id } From f2442ae63932a74f884da047f95d5fe2a1899bb7 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 16:48:09 -0600 Subject: [PATCH 07/12] fix(claude-remote-control): self-heal the tmux session, name claude-here sessions per directory Found on real staging workspaces (dry-drupal-core, dry-drupal-contrib): - The startup script's `if ! tmux has-session` guard assumed a pre-existing session meant claude was already running in it. Not true: the "Claude Code" app button's `tmux new-session -A` fallback can race ahead of the startup script and create a bare-shell session first, which the guard then left alone forever - claude never launched. Separately, a `claude --remote-control` process that never gets logged into/connected can exit on its own, killing the whole tmux server, with no way for the app button to recover it (it only attached-or-created-a-bare-shell). Fix: extract the "is window 0 actually running claude, and if not, (re)launch it" check into a shared ~/.local/bin/claude-ensure script, called both by the startup script and by the coder_app's command before it attaches - so either path can revive a dead or bare session, not just create one once at boot. - claude-here always relaunched under the exact same --remote-control name, so after retargeting to a new directory there was no way to tell the new (live) session apart from the old (now-dead - a respawn never resumes the prior one) sessions of the same name in the claude.ai/code list. Now names each relaunch `$CODER_WORKSPACE_NAME-`; the tmux session name itself (used for all tmux targeting) stays fixed. Co-Authored-By: Claude Sonnet 5 --- .../modules/claude-remote-control/main.tf | 8 ++-- .../modules/claude-remote-control/outputs.tf | 42 ++++++++++++++++--- .../modules/claude-remote-control/main.tf | 8 ++-- .../modules/claude-remote-control/outputs.tf | 42 ++++++++++++++++--- .../modules/claude-remote-control/main.tf | 8 ++-- .../modules/claude-remote-control/outputs.tf | 42 ++++++++++++++++--- modules/claude-remote-control/main.tf | 8 ++-- modules/claude-remote-control/outputs.tf | 42 ++++++++++++++++--- 8 files changed, 164 insertions(+), 36 deletions(-) diff --git a/drupal-contrib/modules/claude-remote-control/main.tf b/drupal-contrib/modules/claude-remote-control/main.tf index bdb3cba..4b0cfec 100644 --- a/drupal-contrib/modules/claude-remote-control/main.tf +++ b/drupal-contrib/modules/claude-remote-control/main.tf @@ -6,7 +6,7 @@ data "coder_parameter" "enable_claude_code" { **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. EOT @@ -35,9 +35,9 @@ locals { # tobool() would reject outright. enabled = data.coder_parameter.enable_claude_code.value == "true" skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", - local.skip_permissions ? "--dangerously-skip-permissions" : "", + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, ])) } @@ -49,6 +49,6 @@ resource "coder_app" "claude_code" { agent_id = var.agent_id slug = "claude-code" display_name = "Claude Code" - command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + command = "~/.local/bin/claude-ensure 2>/dev/null; tmux attach -t \"$CODER_WORKSPACE_NAME:0\"" order = var.app_order } diff --git a/drupal-contrib/modules/claude-remote-control/outputs.tf b/drupal-contrib/modules/claude-remote-control/outputs.tf index 06ee469..5e7dbb2 100644 --- a/drupal-contrib/modules/claude-remote-control/outputs.tf +++ b/drupal-contrib/modules/claude-remote-control/outputs.tf @@ -16,9 +16,11 @@ output "startup_script" { # tmux session so it's connectable from claude.ai/code or the mobile app. CLAUDE_CODE_ENABLED="${local.enabled}" if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then - sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + sed -i '/^export CLAUDE_REMOTE_CMD=/d;/^export CLAUDE_EXTRA_FLAGS=/d' ~/.bashrc || true echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + echo 'export CLAUDE_EXTRA_FLAGS="${local.extra_flags}"' >> ~/.bashrc export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + export CLAUDE_EXTRA_FLAGS="${local.extra_flags}" # Target window 0 explicitly, not just the session: an untargeted # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current @@ -26,18 +28,48 @@ output "startup_script" { # was ever created (e.g. by the user running tmux themselves) and # left focused - respawning the wrong pane instead of the intended # Claude Code session. + # + # The remote-control name includes the current directory's basename + # so each retarget shows up as a visibly different session in + # claude.ai/code - reusing the exact same name every time makes it + # impossible to tell the new (live) session apart from the old + # (now-dead, since a respawn never resumes the prior one) ones in + # that list. mkdir -p ~/.local/bin cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" + DIR_NAME=$(basename "$(pwd)") + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE chmod +x ~/.local/bin/claude-here - if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" - echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + # claude-ensure: makes sure window 0 of the named tmux session is + # actually running claude, (re)launching it if not. Needed because + # tmux has-session alone isn't a sufficient guard: the session could + # already exist but be a bare shell instead of claude - e.g. the + # "Claude Code" app button raced ahead of this script and created it + # first, or the claude process itself already exited (it will, if + # nobody completes login/connects within its idle window) leaving the + # tmux server dead entirely. Shared by both this startup script and + # the app button's command so either one can revive a dead/bare + # session, not just create it once at boot. + cat > ~/.local/bin/claude-ensure <<-'CLAUDEENSURE' + #!/usr/bin/env bash + set -euo pipefail + if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + CURRENT_CMD=$(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_current_command}' 2>/dev/null | head -1) + if [ "$CURRENT_CMD" != "claude" ]; then + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" fi + else + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + fi + CLAUDEENSURE + chmod +x ~/.local/bin/claude-ensure + + ~/.local/bin/claude-ensure + echo "✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME" fi EOT } diff --git a/drupal-core/modules/claude-remote-control/main.tf b/drupal-core/modules/claude-remote-control/main.tf index bdb3cba..4b0cfec 100644 --- a/drupal-core/modules/claude-remote-control/main.tf +++ b/drupal-core/modules/claude-remote-control/main.tf @@ -6,7 +6,7 @@ data "coder_parameter" "enable_claude_code" { **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. EOT @@ -35,9 +35,9 @@ locals { # tobool() would reject outright. enabled = data.coder_parameter.enable_claude_code.value == "true" skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", - local.skip_permissions ? "--dangerously-skip-permissions" : "", + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, ])) } @@ -49,6 +49,6 @@ resource "coder_app" "claude_code" { agent_id = var.agent_id slug = "claude-code" display_name = "Claude Code" - command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + command = "~/.local/bin/claude-ensure 2>/dev/null; tmux attach -t \"$CODER_WORKSPACE_NAME:0\"" order = var.app_order } diff --git a/drupal-core/modules/claude-remote-control/outputs.tf b/drupal-core/modules/claude-remote-control/outputs.tf index 06ee469..5e7dbb2 100644 --- a/drupal-core/modules/claude-remote-control/outputs.tf +++ b/drupal-core/modules/claude-remote-control/outputs.tf @@ -16,9 +16,11 @@ output "startup_script" { # tmux session so it's connectable from claude.ai/code or the mobile app. CLAUDE_CODE_ENABLED="${local.enabled}" if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then - sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + sed -i '/^export CLAUDE_REMOTE_CMD=/d;/^export CLAUDE_EXTRA_FLAGS=/d' ~/.bashrc || true echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + echo 'export CLAUDE_EXTRA_FLAGS="${local.extra_flags}"' >> ~/.bashrc export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + export CLAUDE_EXTRA_FLAGS="${local.extra_flags}" # Target window 0 explicitly, not just the session: an untargeted # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current @@ -26,18 +28,48 @@ output "startup_script" { # was ever created (e.g. by the user running tmux themselves) and # left focused - respawning the wrong pane instead of the intended # Claude Code session. + # + # The remote-control name includes the current directory's basename + # so each retarget shows up as a visibly different session in + # claude.ai/code - reusing the exact same name every time makes it + # impossible to tell the new (live) session apart from the old + # (now-dead, since a respawn never resumes the prior one) ones in + # that list. mkdir -p ~/.local/bin cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" + DIR_NAME=$(basename "$(pwd)") + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE chmod +x ~/.local/bin/claude-here - if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" - echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + # claude-ensure: makes sure window 0 of the named tmux session is + # actually running claude, (re)launching it if not. Needed because + # tmux has-session alone isn't a sufficient guard: the session could + # already exist but be a bare shell instead of claude - e.g. the + # "Claude Code" app button raced ahead of this script and created it + # first, or the claude process itself already exited (it will, if + # nobody completes login/connects within its idle window) leaving the + # tmux server dead entirely. Shared by both this startup script and + # the app button's command so either one can revive a dead/bare + # session, not just create it once at boot. + cat > ~/.local/bin/claude-ensure <<-'CLAUDEENSURE' + #!/usr/bin/env bash + set -euo pipefail + if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + CURRENT_CMD=$(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_current_command}' 2>/dev/null | head -1) + if [ "$CURRENT_CMD" != "claude" ]; then + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" fi + else + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + fi + CLAUDEENSURE + chmod +x ~/.local/bin/claude-ensure + + ~/.local/bin/claude-ensure + echo "✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME" fi EOT } diff --git a/freeform/modules/claude-remote-control/main.tf b/freeform/modules/claude-remote-control/main.tf index bdb3cba..4b0cfec 100644 --- a/freeform/modules/claude-remote-control/main.tf +++ b/freeform/modules/claude-remote-control/main.tf @@ -6,7 +6,7 @@ data "coder_parameter" "enable_claude_code" { **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. EOT @@ -35,9 +35,9 @@ locals { # tobool() would reject outright. enabled = data.coder_parameter.enable_claude_code.value == "true" skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", - local.skip_permissions ? "--dangerously-skip-permissions" : "", + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, ])) } @@ -49,6 +49,6 @@ resource "coder_app" "claude_code" { agent_id = var.agent_id slug = "claude-code" display_name = "Claude Code" - command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + command = "~/.local/bin/claude-ensure 2>/dev/null; tmux attach -t \"$CODER_WORKSPACE_NAME:0\"" order = var.app_order } diff --git a/freeform/modules/claude-remote-control/outputs.tf b/freeform/modules/claude-remote-control/outputs.tf index 06ee469..5e7dbb2 100644 --- a/freeform/modules/claude-remote-control/outputs.tf +++ b/freeform/modules/claude-remote-control/outputs.tf @@ -16,9 +16,11 @@ output "startup_script" { # tmux session so it's connectable from claude.ai/code or the mobile app. CLAUDE_CODE_ENABLED="${local.enabled}" if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then - sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + sed -i '/^export CLAUDE_REMOTE_CMD=/d;/^export CLAUDE_EXTRA_FLAGS=/d' ~/.bashrc || true echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + echo 'export CLAUDE_EXTRA_FLAGS="${local.extra_flags}"' >> ~/.bashrc export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + export CLAUDE_EXTRA_FLAGS="${local.extra_flags}" # Target window 0 explicitly, not just the session: an untargeted # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current @@ -26,18 +28,48 @@ output "startup_script" { # was ever created (e.g. by the user running tmux themselves) and # left focused - respawning the wrong pane instead of the intended # Claude Code session. + # + # The remote-control name includes the current directory's basename + # so each retarget shows up as a visibly different session in + # claude.ai/code - reusing the exact same name every time makes it + # impossible to tell the new (live) session apart from the old + # (now-dead, since a respawn never resumes the prior one) ones in + # that list. mkdir -p ~/.local/bin cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" + DIR_NAME=$(basename "$(pwd)") + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE chmod +x ~/.local/bin/claude-here - if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" - echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + # claude-ensure: makes sure window 0 of the named tmux session is + # actually running claude, (re)launching it if not. Needed because + # tmux has-session alone isn't a sufficient guard: the session could + # already exist but be a bare shell instead of claude - e.g. the + # "Claude Code" app button raced ahead of this script and created it + # first, or the claude process itself already exited (it will, if + # nobody completes login/connects within its idle window) leaving the + # tmux server dead entirely. Shared by both this startup script and + # the app button's command so either one can revive a dead/bare + # session, not just create it once at boot. + cat > ~/.local/bin/claude-ensure <<-'CLAUDEENSURE' + #!/usr/bin/env bash + set -euo pipefail + if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + CURRENT_CMD=$(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_current_command}' 2>/dev/null | head -1) + if [ "$CURRENT_CMD" != "claude" ]; then + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" fi + else + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + fi + CLAUDEENSURE + chmod +x ~/.local/bin/claude-ensure + + ~/.local/bin/claude-ensure + echo "✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME" fi EOT } diff --git a/modules/claude-remote-control/main.tf b/modules/claude-remote-control/main.tf index bdb3cba..4b0cfec 100644 --- a/modules/claude-remote-control/main.tf +++ b/modules/claude-remote-control/main.tf @@ -6,7 +6,7 @@ data "coder_parameter" "enable_claude_code" { **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This replaces the current conversation. + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. EOT @@ -35,9 +35,9 @@ locals { # tobool() would reject outright. enabled = data.coder_parameter.enable_claude_code.value == "true" skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", - local.skip_permissions ? "--dangerously-skip-permissions" : "", + "claude", "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, ])) } @@ -49,6 +49,6 @@ resource "coder_app" "claude_code" { agent_id = var.agent_id slug = "claude-code" display_name = "Claude Code" - command = "tmux attach -t \"$CODER_WORKSPACE_NAME:0\" || tmux new-session -A -s \"$CODER_WORKSPACE_NAME\"" + command = "~/.local/bin/claude-ensure 2>/dev/null; tmux attach -t \"$CODER_WORKSPACE_NAME:0\"" order = var.app_order } diff --git a/modules/claude-remote-control/outputs.tf b/modules/claude-remote-control/outputs.tf index 06ee469..5e7dbb2 100644 --- a/modules/claude-remote-control/outputs.tf +++ b/modules/claude-remote-control/outputs.tf @@ -16,9 +16,11 @@ output "startup_script" { # tmux session so it's connectable from claude.ai/code or the mobile app. CLAUDE_CODE_ENABLED="${local.enabled}" if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then - sed -i '/^export CLAUDE_REMOTE_CMD=/d' ~/.bashrc || true + sed -i '/^export CLAUDE_REMOTE_CMD=/d;/^export CLAUDE_EXTRA_FLAGS=/d' ~/.bashrc || true echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + echo 'export CLAUDE_EXTRA_FLAGS="${local.extra_flags}"' >> ~/.bashrc export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + export CLAUDE_EXTRA_FLAGS="${local.extra_flags}" # Target window 0 explicitly, not just the session: an untargeted # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current @@ -26,18 +28,48 @@ output "startup_script" { # was ever created (e.g. by the user running tmux themselves) and # left focused - respawning the wrong pane instead of the intended # Claude Code session. + # + # The remote-control name includes the current directory's basename + # so each retarget shows up as a visibly different session in + # claude.ai/code - reusing the exact same name every time makes it + # impossible to tell the new (live) session apart from the old + # (now-dead, since a respawn never resumes the prior one) ones in + # that list. mkdir -p ~/.local/bin cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "$CLAUDE_REMOTE_CMD" + DIR_NAME=$(basename "$(pwd)") + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE chmod +x ~/.local/bin/claude-here - if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" - echo "✓ Claude Code remote control session started: $CODER_WORKSPACE_NAME" + # claude-ensure: makes sure window 0 of the named tmux session is + # actually running claude, (re)launching it if not. Needed because + # tmux has-session alone isn't a sufficient guard: the session could + # already exist but be a bare shell instead of claude - e.g. the + # "Claude Code" app button raced ahead of this script and created it + # first, or the claude process itself already exited (it will, if + # nobody completes login/connects within its idle window) leaving the + # tmux server dead entirely. Shared by both this startup script and + # the app button's command so either one can revive a dead/bare + # session, not just create it once at boot. + cat > ~/.local/bin/claude-ensure <<-'CLAUDEENSURE' + #!/usr/bin/env bash + set -euo pipefail + if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + CURRENT_CMD=$(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_current_command}' 2>/dev/null | head -1) + if [ "$CURRENT_CMD" != "claude" ]; then + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" fi + else + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + fi + CLAUDEENSURE + chmod +x ~/.local/bin/claude-ensure + + ~/.local/bin/claude-ensure + echo "✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME" fi EOT } From 834f2f4b21bd59ab644921740e64f0d41b39d907 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 16:59:57 -0600 Subject: [PATCH 08/12] fix(ci): add --use-parameter-defaults to coder create so new params don't hang CI Adding enable_claude_code/claude_code_skip_permissions coder_parameters broke every CI integration-test `coder create` invocation that didn't explicitly enumerate every parameter: any coder_parameter without an explicit --parameter value gets interactively prompted for, and on non-interactive CI stdin that prompt immediately hits EOF ("error: prepare build: EOF"), failing the job. --use-parameter-defaults tells the CLI to fall back to each unspecified parameter's default instead of prompting, so this doesn't need to be re-fixed the next time a template gains a new optional parameter. Verified locally against staging-coder with the exact drupal-core CI flag set (no --parameter for the two new params): create succeeds without any prompt. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/drupal-contrib-integration-test.yml | 4 ++++ .github/workflows/drupal-integration-test.yml | 4 ++++ .github/workflows/integration-test.yml | 2 ++ 3 files changed, 10 insertions(+) diff --git a/.github/workflows/drupal-contrib-integration-test.yml b/.github/workflows/drupal-contrib-integration-test.yml index 039266d..00af395 100644 --- a/.github/workflows/drupal-contrib-integration-test.yml +++ b/.github/workflows/drupal-contrib-integration-test.yml @@ -133,6 +133,7 @@ jobs: --parameter issue_branch= \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected @@ -316,6 +317,7 @@ jobs: --parameter issue_branch=${{ env.ISSUE_BRANCH }} \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected @@ -474,6 +476,7 @@ jobs: --parameter issue_branch= \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected @@ -633,6 +636,7 @@ jobs: --parameter issue_branch=${{ env.ISSUE_BRANCH }} \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected diff --git a/.github/workflows/drupal-integration-test.yml b/.github/workflows/drupal-integration-test.yml index c12ee0e..b9f1840 100644 --- a/.github/workflows/drupal-integration-test.yml +++ b/.github/workflows/drupal-integration-test.yml @@ -132,6 +132,7 @@ jobs: --parameter issue_branch= \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - &verify-agent @@ -279,6 +280,7 @@ jobs: --parameter issue_branch= \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - *verify-agent @@ -383,6 +385,7 @@ jobs: --parameter issue_branch=${{ env.ISSUE_BRANCH }} \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected @@ -539,6 +542,7 @@ jobs: --parameter issue_branch=${{ env.ISSUE_BRANCH }} \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 2a19a20..7ac7c6c 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -149,6 +149,7 @@ jobs: --template-version ci-${{ env.CI_TAG }} \ --parameter "vscode_extensions=[]" \ ${{ matrix.extra_params }} \ + --use-parameter-defaults \ --yes - name: Create freeform workspace (with project names) @@ -371,6 +372,7 @@ jobs: --template-version ci-gh-${{ env.CI_TAG }} \ --parameter "vscode_extensions=[]" \ ${{ matrix.extra_params }} \ + --use-parameter-defaults \ --yes - name: Create freeform workspace (with project names) From d9c07ee81d0e99dc1fb182f4101fd736622a2902 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 17:11:04 -0600 Subject: [PATCH 09/12] fix(claude-remote-control): survive claude exiting, surface setup instructions Reproduced twice on a real staging workspace: if nobody logs into the initial claude --remote-control session before its idle timeout, the process exits - and since it was the only tmux pane, the whole tmux server exits with it, leaving no session at all for claude-ensure (or the app button) to detect and revive. Set remain-on-exit on the session so a dead claude leaves a dead pane behind instead of taking the server down - claude-ensure now checks pane_dead in addition to pane_current_command and respawns either way. Also: the only sign Claude Code was enabled and needed action was a terse one-line "session ready" status buried in the build log. Print a clearer instruction block (open the app button/SSH in to finish login; cd + claude-here for a project directory) and append it to WELCOME.txt so it's visible again on every later login, not just a one-time scrollback. Co-Authored-By: Claude Sonnet 5 --- .../modules/claude-remote-control/outputs.tf | 26 ++++++++++++++++--- .../modules/claude-remote-control/outputs.tf | 26 ++++++++++++++++--- .../modules/claude-remote-control/outputs.tf | 26 ++++++++++++++++--- modules/claude-remote-control/outputs.tf | 26 ++++++++++++++++--- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/drupal-contrib/modules/claude-remote-control/outputs.tf b/drupal-contrib/modules/claude-remote-control/outputs.tf index 5e7dbb2..96f99c2 100644 --- a/drupal-contrib/modules/claude-remote-control/outputs.tf +++ b/drupal-contrib/modules/claude-remote-control/outputs.tf @@ -58,18 +58,38 @@ output "startup_script" { #!/usr/bin/env bash set -euo pipefail if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - CURRENT_CMD=$(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_current_command}' 2>/dev/null | head -1) - if [ "$CURRENT_CMD" != "claude" ]; then + # remain-on-exit keeps the pane (and so the session/server) around even + # after claude exits - e.g. if nobody logs in before its idle timeout. + # Without it, an unattended claude exiting takes the whole tmux server + # down with it, with no session left for claude-ensure to even detect. + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on 2>/dev/null || true + read -r PANE_DEAD CURRENT_CMD < <(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_dead} #{pane_current_command}' 2>/dev/null | head -1) + if [ "$PANE_DEAD" = "1" ] || [ "$CURRENT_CMD" != "claude" ]; then tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" fi else tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on fi CLAUDEENSURE chmod +x ~/.local/bin/claude-ensure ~/.local/bin/claude-ensure - echo "✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME" + + # Printed to the live build log so it's seen during the build, not + # just buried in a one-line status - and appended to WELCOME.txt so + # it's seen again on every later login, since the build log usually + # isn't scrolled back to. + CLAUDE_CODE_BANNER=" +✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME + + → First time? Open the \"Claude Code\" app button (or SSH in) to finish + logging in and accept the workspace-trust prompt - the session won't + show up on claude.ai/code or the mobile app until you do. + → Working in a project directory? cd there and run: claude-here +" + echo "$CLAUDE_CODE_BANNER" + echo "$CLAUDE_CODE_BANNER" >> ~/WELCOME.txt fi EOT } diff --git a/drupal-core/modules/claude-remote-control/outputs.tf b/drupal-core/modules/claude-remote-control/outputs.tf index 5e7dbb2..96f99c2 100644 --- a/drupal-core/modules/claude-remote-control/outputs.tf +++ b/drupal-core/modules/claude-remote-control/outputs.tf @@ -58,18 +58,38 @@ output "startup_script" { #!/usr/bin/env bash set -euo pipefail if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - CURRENT_CMD=$(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_current_command}' 2>/dev/null | head -1) - if [ "$CURRENT_CMD" != "claude" ]; then + # remain-on-exit keeps the pane (and so the session/server) around even + # after claude exits - e.g. if nobody logs in before its idle timeout. + # Without it, an unattended claude exiting takes the whole tmux server + # down with it, with no session left for claude-ensure to even detect. + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on 2>/dev/null || true + read -r PANE_DEAD CURRENT_CMD < <(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_dead} #{pane_current_command}' 2>/dev/null | head -1) + if [ "$PANE_DEAD" = "1" ] || [ "$CURRENT_CMD" != "claude" ]; then tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" fi else tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on fi CLAUDEENSURE chmod +x ~/.local/bin/claude-ensure ~/.local/bin/claude-ensure - echo "✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME" + + # Printed to the live build log so it's seen during the build, not + # just buried in a one-line status - and appended to WELCOME.txt so + # it's seen again on every later login, since the build log usually + # isn't scrolled back to. + CLAUDE_CODE_BANNER=" +✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME + + → First time? Open the \"Claude Code\" app button (or SSH in) to finish + logging in and accept the workspace-trust prompt - the session won't + show up on claude.ai/code or the mobile app until you do. + → Working in a project directory? cd there and run: claude-here +" + echo "$CLAUDE_CODE_BANNER" + echo "$CLAUDE_CODE_BANNER" >> ~/WELCOME.txt fi EOT } diff --git a/freeform/modules/claude-remote-control/outputs.tf b/freeform/modules/claude-remote-control/outputs.tf index 5e7dbb2..96f99c2 100644 --- a/freeform/modules/claude-remote-control/outputs.tf +++ b/freeform/modules/claude-remote-control/outputs.tf @@ -58,18 +58,38 @@ output "startup_script" { #!/usr/bin/env bash set -euo pipefail if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - CURRENT_CMD=$(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_current_command}' 2>/dev/null | head -1) - if [ "$CURRENT_CMD" != "claude" ]; then + # remain-on-exit keeps the pane (and so the session/server) around even + # after claude exits - e.g. if nobody logs in before its idle timeout. + # Without it, an unattended claude exiting takes the whole tmux server + # down with it, with no session left for claude-ensure to even detect. + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on 2>/dev/null || true + read -r PANE_DEAD CURRENT_CMD < <(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_dead} #{pane_current_command}' 2>/dev/null | head -1) + if [ "$PANE_DEAD" = "1" ] || [ "$CURRENT_CMD" != "claude" ]; then tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" fi else tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on fi CLAUDEENSURE chmod +x ~/.local/bin/claude-ensure ~/.local/bin/claude-ensure - echo "✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME" + + # Printed to the live build log so it's seen during the build, not + # just buried in a one-line status - and appended to WELCOME.txt so + # it's seen again on every later login, since the build log usually + # isn't scrolled back to. + CLAUDE_CODE_BANNER=" +✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME + + → First time? Open the \"Claude Code\" app button (or SSH in) to finish + logging in and accept the workspace-trust prompt - the session won't + show up on claude.ai/code or the mobile app until you do. + → Working in a project directory? cd there and run: claude-here +" + echo "$CLAUDE_CODE_BANNER" + echo "$CLAUDE_CODE_BANNER" >> ~/WELCOME.txt fi EOT } diff --git a/modules/claude-remote-control/outputs.tf b/modules/claude-remote-control/outputs.tf index 5e7dbb2..96f99c2 100644 --- a/modules/claude-remote-control/outputs.tf +++ b/modules/claude-remote-control/outputs.tf @@ -58,18 +58,38 @@ output "startup_script" { #!/usr/bin/env bash set -euo pipefail if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then - CURRENT_CMD=$(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_current_command}' 2>/dev/null | head -1) - if [ "$CURRENT_CMD" != "claude" ]; then + # remain-on-exit keeps the pane (and so the session/server) around even + # after claude exits - e.g. if nobody logs in before its idle timeout. + # Without it, an unattended claude exiting takes the whole tmux server + # down with it, with no session left for claude-ensure to even detect. + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on 2>/dev/null || true + read -r PANE_DEAD CURRENT_CMD < <(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_dead} #{pane_current_command}' 2>/dev/null | head -1) + if [ "$PANE_DEAD" = "1" ] || [ "$CURRENT_CMD" != "claude" ]; then tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" fi else tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on fi CLAUDEENSURE chmod +x ~/.local/bin/claude-ensure ~/.local/bin/claude-ensure - echo "✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME" + + # Printed to the live build log so it's seen during the build, not + # just buried in a one-line status - and appended to WELCOME.txt so + # it's seen again on every later login, since the build log usually + # isn't scrolled back to. + CLAUDE_CODE_BANNER=" +✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME + + → First time? Open the \"Claude Code\" app button (or SSH in) to finish + logging in and accept the workspace-trust prompt - the session won't + show up on claude.ai/code or the mobile app until you do. + → Working in a project directory? cd there and run: claude-here +" + echo "$CLAUDE_CODE_BANNER" + echo "$CLAUDE_CODE_BANNER" >> ~/WELCOME.txt fi EOT } From 6f30c0c74356938b516bdfd63e6fbc5698d51eda Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 17:23:21 -0600 Subject: [PATCH 10/12] feat(claude-remote-control): auto-trust $HOME subdirectories in claude-here $HOME is already an accepted workspace-trust boundary (established once at first login), but claude's trust store (~/.claude.json, projects. .hasTrustDialogAccepted) is keyed per exact path, not inherited by subdirectories. So every claude-here retarget into a new project directory under $HOME re-triggered the trust dialog - with nobody there to answer it, since claude-here just launches a background tmux pane. Remote control wouldn't come online until someone happened to go check and accept it manually, which is easy to forget and easy to mistake for a dead session. claude-here now pre-marks the target directory as trusted via jq before launching, for any directory under $HOME - the same boundary already implicitly trusted. Directories outside $HOME are left alone (still prompt normally), with an explicit reminder printed in the invoking shell (not the respawned pane, so it's actually seen) that a manual trust step is needed there. Verified against a real staging workspace: patching hasTrustDialogAccepted before launch skips the dialog entirely - straight to /remote-control is active with a fresh session URL. Co-Authored-By: Claude Sonnet 5 --- .../modules/claude-remote-control/main.tf | 2 +- .../modules/claude-remote-control/outputs.tf | 29 +++++++++++++++++-- .../modules/claude-remote-control/main.tf | 2 +- .../modules/claude-remote-control/outputs.tf | 29 +++++++++++++++++-- .../modules/claude-remote-control/main.tf | 2 +- .../modules/claude-remote-control/outputs.tf | 29 +++++++++++++++++-- modules/claude-remote-control/main.tf | 2 +- modules/claude-remote-control/outputs.tf | 29 +++++++++++++++++-- 8 files changed, 112 insertions(+), 12 deletions(-) diff --git a/drupal-contrib/modules/claude-remote-control/main.tf b/drupal-contrib/modules/claude-remote-control/main.tf index 4b0cfec..cf003ca 100644 --- a/drupal-contrib/modules/claude-remote-control/main.tf +++ b/drupal-contrib/modules/claude-remote-control/main.tf @@ -6,7 +6,7 @@ data "coder_parameter" "enable_claude_code" { **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, and directories under `$HOME` are auto-trusted (same trust boundary as `$HOME` itself), so no trust prompt either. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. EOT diff --git a/drupal-contrib/modules/claude-remote-control/outputs.tf b/drupal-contrib/modules/claude-remote-control/outputs.tf index 96f99c2..2a8b2c7 100644 --- a/drupal-contrib/modules/claude-remote-control/outputs.tf +++ b/drupal-contrib/modules/claude-remote-control/outputs.tf @@ -39,8 +39,33 @@ output "startup_script" { cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail - DIR_NAME=$(basename "$(pwd)") - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" + TARGET_DIR="$(pwd)" + DIR_NAME=$(basename "$TARGET_DIR") + + case "$TARGET_DIR" in + "$HOME"/*) + # $HOME itself is already a trusted workspace boundary (accepted once + # at first login) - claude's trust store is keyed per exact path, not + # inherited by subdirectories, so without this every new project + # directory under $HOME would re-prompt for a dialog nobody's there + # to answer (this just launches a background tmux pane) - remote + # control won't come online until someone manually accepts it. + if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + CLAUDE_JSON_TMP=$(mktemp) + if jq --arg dir "$TARGET_DIR" '.projects[$dir].hasTrustDialogAccepted = true' "$HOME/.claude.json" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv "$CLAUDE_JSON_TMP" "$HOME/.claude.json" + else + rm -f "$CLAUDE_JSON_TMP" + fi + fi + ;; + *) + echo "Note: $TARGET_DIR is outside \$HOME, so it isn't auto-trusted." + echo "Go accept the workspace-trust prompt in the Claude Code session before it starts serving." + ;; + esac + + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE chmod +x ~/.local/bin/claude-here diff --git a/drupal-core/modules/claude-remote-control/main.tf b/drupal-core/modules/claude-remote-control/main.tf index 4b0cfec..cf003ca 100644 --- a/drupal-core/modules/claude-remote-control/main.tf +++ b/drupal-core/modules/claude-remote-control/main.tf @@ -6,7 +6,7 @@ data "coder_parameter" "enable_claude_code" { **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, and directories under `$HOME` are auto-trusted (same trust boundary as `$HOME` itself), so no trust prompt either. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. EOT diff --git a/drupal-core/modules/claude-remote-control/outputs.tf b/drupal-core/modules/claude-remote-control/outputs.tf index 96f99c2..2a8b2c7 100644 --- a/drupal-core/modules/claude-remote-control/outputs.tf +++ b/drupal-core/modules/claude-remote-control/outputs.tf @@ -39,8 +39,33 @@ output "startup_script" { cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail - DIR_NAME=$(basename "$(pwd)") - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" + TARGET_DIR="$(pwd)" + DIR_NAME=$(basename "$TARGET_DIR") + + case "$TARGET_DIR" in + "$HOME"/*) + # $HOME itself is already a trusted workspace boundary (accepted once + # at first login) - claude's trust store is keyed per exact path, not + # inherited by subdirectories, so without this every new project + # directory under $HOME would re-prompt for a dialog nobody's there + # to answer (this just launches a background tmux pane) - remote + # control won't come online until someone manually accepts it. + if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + CLAUDE_JSON_TMP=$(mktemp) + if jq --arg dir "$TARGET_DIR" '.projects[$dir].hasTrustDialogAccepted = true' "$HOME/.claude.json" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv "$CLAUDE_JSON_TMP" "$HOME/.claude.json" + else + rm -f "$CLAUDE_JSON_TMP" + fi + fi + ;; + *) + echo "Note: $TARGET_DIR is outside \$HOME, so it isn't auto-trusted." + echo "Go accept the workspace-trust prompt in the Claude Code session before it starts serving." + ;; + esac + + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE chmod +x ~/.local/bin/claude-here diff --git a/freeform/modules/claude-remote-control/main.tf b/freeform/modules/claude-remote-control/main.tf index 4b0cfec..cf003ca 100644 --- a/freeform/modules/claude-remote-control/main.tf +++ b/freeform/modules/claude-remote-control/main.tf @@ -6,7 +6,7 @@ data "coder_parameter" "enable_claude_code" { **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, and directories under `$HOME` are auto-trusted (same trust boundary as `$HOME` itself), so no trust prompt either. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. EOT diff --git a/freeform/modules/claude-remote-control/outputs.tf b/freeform/modules/claude-remote-control/outputs.tf index 96f99c2..2a8b2c7 100644 --- a/freeform/modules/claude-remote-control/outputs.tf +++ b/freeform/modules/claude-remote-control/outputs.tf @@ -39,8 +39,33 @@ output "startup_script" { cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail - DIR_NAME=$(basename "$(pwd)") - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" + TARGET_DIR="$(pwd)" + DIR_NAME=$(basename "$TARGET_DIR") + + case "$TARGET_DIR" in + "$HOME"/*) + # $HOME itself is already a trusted workspace boundary (accepted once + # at first login) - claude's trust store is keyed per exact path, not + # inherited by subdirectories, so without this every new project + # directory under $HOME would re-prompt for a dialog nobody's there + # to answer (this just launches a background tmux pane) - remote + # control won't come online until someone manually accepts it. + if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + CLAUDE_JSON_TMP=$(mktemp) + if jq --arg dir "$TARGET_DIR" '.projects[$dir].hasTrustDialogAccepted = true' "$HOME/.claude.json" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv "$CLAUDE_JSON_TMP" "$HOME/.claude.json" + else + rm -f "$CLAUDE_JSON_TMP" + fi + fi + ;; + *) + echo "Note: $TARGET_DIR is outside \$HOME, so it isn't auto-trusted." + echo "Go accept the workspace-trust prompt in the Claude Code session before it starts serving." + ;; + esac + + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE chmod +x ~/.local/bin/claude-here diff --git a/modules/claude-remote-control/main.tf b/modules/claude-remote-control/main.tf index 4b0cfec..cf003ca 100644 --- a/modules/claude-remote-control/main.tf +++ b/modules/claude-remote-control/main.tf @@ -6,7 +6,7 @@ data "coder_parameter" "enable_claude_code" { **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. - **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, just a one-time trust prompt for that directory. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, and directories under `$HOME` are auto-trusted (same trust boundary as `$HOME` itself), so no trust prompt either. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. EOT diff --git a/modules/claude-remote-control/outputs.tf b/modules/claude-remote-control/outputs.tf index 96f99c2..2a8b2c7 100644 --- a/modules/claude-remote-control/outputs.tf +++ b/modules/claude-remote-control/outputs.tf @@ -39,8 +39,33 @@ output "startup_script" { cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' #!/usr/bin/env bash set -euo pipefail - DIR_NAME=$(basename "$(pwd)") - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$(pwd)" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" + TARGET_DIR="$(pwd)" + DIR_NAME=$(basename "$TARGET_DIR") + + case "$TARGET_DIR" in + "$HOME"/*) + # $HOME itself is already a trusted workspace boundary (accepted once + # at first login) - claude's trust store is keyed per exact path, not + # inherited by subdirectories, so without this every new project + # directory under $HOME would re-prompt for a dialog nobody's there + # to answer (this just launches a background tmux pane) - remote + # control won't come online until someone manually accepts it. + if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + CLAUDE_JSON_TMP=$(mktemp) + if jq --arg dir "$TARGET_DIR" '.projects[$dir].hasTrustDialogAccepted = true' "$HOME/.claude.json" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv "$CLAUDE_JSON_TMP" "$HOME/.claude.json" + else + rm -f "$CLAUDE_JSON_TMP" + fi + fi + ;; + *) + echo "Note: $TARGET_DIR is outside \$HOME, so it isn't auto-trusted." + echo "Go accept the workspace-trust prompt in the Claude Code session before it starts serving." + ;; + esac + + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE chmod +x ~/.local/bin/claude-here From 226ad9f88a77822bde60ebe2b7ad180a19be1c63 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 17:34:23 -0600 Subject: [PATCH 11/12] fix(claude-remote-control): fix broken nested heredoc breaking startup script The CLAUDEHERE/CLAUDEENSURE heredoc terminators had leading whitespace left over from the outer Terraform <<-EOT heredoc's source indentation. Terraform doesn't strip it (a blank line early in the heredoc body makes the common-indentation prefix empty, so no dedent happens at all), and bash's own <<- only strips leading tabs from the terminator line, not spaces - so the nested heredocs were never terminated. bash then silently swallowed the rest of the startup script as heredoc body until reaching real EOF, at which point the whole script failed with a syntax error and nothing after the first `cat > claude-here <<-'CLAUDEHERE'` line ever ran. This was missed because prior verification tested a manually-installed copy of the script content, not the actual Terraform-rendered startup_script - the first real workspace rebuild against the new template hit it on all three templates (freeform, drupal-core, drupal-contrib) identically, since they all share this module. Fixed by placing both terminators at column 0. --- drupal-contrib/modules/claude-remote-control/outputs.tf | 9 +++++++-- drupal-core/modules/claude-remote-control/outputs.tf | 9 +++++++-- freeform/modules/claude-remote-control/outputs.tf | 9 +++++++-- modules/claude-remote-control/outputs.tf | 9 +++++++-- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/drupal-contrib/modules/claude-remote-control/outputs.tf b/drupal-contrib/modules/claude-remote-control/outputs.tf index 2a8b2c7..64ba531 100644 --- a/drupal-contrib/modules/claude-remote-control/outputs.tf +++ b/drupal-contrib/modules/claude-remote-control/outputs.tf @@ -66,7 +66,11 @@ output "startup_script" { esac tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" - CLAUDEHERE +CLAUDEHERE + # ^ must stay at column 0: bash's <<- only strips leading TABS from the + # terminator line, not spaces, and this file is space-indented - any + # indentation here makes the heredoc unterminated (silently swallows the + # rest of the script as its body, a syntax error only bash notices). chmod +x ~/.local/bin/claude-here # claude-ensure: makes sure window 0 of the named tmux session is @@ -96,7 +100,8 @@ output "startup_script" { tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on fi - CLAUDEENSURE +CLAUDEENSURE + # ^ column 0 for the same reason as CLAUDEHERE above. chmod +x ~/.local/bin/claude-ensure ~/.local/bin/claude-ensure diff --git a/drupal-core/modules/claude-remote-control/outputs.tf b/drupal-core/modules/claude-remote-control/outputs.tf index 2a8b2c7..64ba531 100644 --- a/drupal-core/modules/claude-remote-control/outputs.tf +++ b/drupal-core/modules/claude-remote-control/outputs.tf @@ -66,7 +66,11 @@ output "startup_script" { esac tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" - CLAUDEHERE +CLAUDEHERE + # ^ must stay at column 0: bash's <<- only strips leading TABS from the + # terminator line, not spaces, and this file is space-indented - any + # indentation here makes the heredoc unterminated (silently swallows the + # rest of the script as its body, a syntax error only bash notices). chmod +x ~/.local/bin/claude-here # claude-ensure: makes sure window 0 of the named tmux session is @@ -96,7 +100,8 @@ output "startup_script" { tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on fi - CLAUDEENSURE +CLAUDEENSURE + # ^ column 0 for the same reason as CLAUDEHERE above. chmod +x ~/.local/bin/claude-ensure ~/.local/bin/claude-ensure diff --git a/freeform/modules/claude-remote-control/outputs.tf b/freeform/modules/claude-remote-control/outputs.tf index 2a8b2c7..64ba531 100644 --- a/freeform/modules/claude-remote-control/outputs.tf +++ b/freeform/modules/claude-remote-control/outputs.tf @@ -66,7 +66,11 @@ output "startup_script" { esac tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" - CLAUDEHERE +CLAUDEHERE + # ^ must stay at column 0: bash's <<- only strips leading TABS from the + # terminator line, not spaces, and this file is space-indented - any + # indentation here makes the heredoc unterminated (silently swallows the + # rest of the script as its body, a syntax error only bash notices). chmod +x ~/.local/bin/claude-here # claude-ensure: makes sure window 0 of the named tmux session is @@ -96,7 +100,8 @@ output "startup_script" { tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on fi - CLAUDEENSURE +CLAUDEENSURE + # ^ column 0 for the same reason as CLAUDEHERE above. chmod +x ~/.local/bin/claude-ensure ~/.local/bin/claude-ensure diff --git a/modules/claude-remote-control/outputs.tf b/modules/claude-remote-control/outputs.tf index 2a8b2c7..64ba531 100644 --- a/modules/claude-remote-control/outputs.tf +++ b/modules/claude-remote-control/outputs.tf @@ -66,7 +66,11 @@ output "startup_script" { esac tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" - CLAUDEHERE +CLAUDEHERE + # ^ must stay at column 0: bash's <<- only strips leading TABS from the + # terminator line, not spaces, and this file is space-indented - any + # indentation here makes the heredoc unterminated (silently swallows the + # rest of the script as its body, a syntax error only bash notices). chmod +x ~/.local/bin/claude-here # claude-ensure: makes sure window 0 of the named tmux session is @@ -96,7 +100,8 @@ output "startup_script" { tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on fi - CLAUDEENSURE +CLAUDEENSURE + # ^ column 0 for the same reason as CLAUDEHERE above. chmod +x ~/.local/bin/claude-ensure ~/.local/bin/claude-ensure From 1e0772b93d47136993c9eef7555a071ef2c2f318 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Mon, 27 Jul 2026 17:44:22 -0600 Subject: [PATCH 12/12] fix(claude-remote-control): invoke claude by absolute path, not bare PATH lookup `claude` is installed via Homebrew (linuxbrew) at /home/linuxbrew/.linuxbrew/bin/claude, which is only added to PATH in ~/.bashrc - sourced for interactive shells only. tmux new-session and respawn-pane run their command directly (not through a login/interactive shell), so a bare `claude` invocation fails with "command not found" the first time a session is created at boot. This was previously masked because the heredoc bug (prior commit) always killed the startup script before reaching this point in a fresh container, and every live test session had so far been hand-created over an interactive SSH connection where PATH was already correct. Fixed by invoking the absolute path to the claude binary everywhere the module launches it (remote_cmd and the claude-here tmux respawn-pane command), instead of depending on PATH inheritance through tmux. --- drupal-contrib/modules/claude-remote-control/main.tf | 9 ++++++++- drupal-contrib/modules/claude-remote-control/outputs.tf | 2 +- drupal-core/modules/claude-remote-control/main.tf | 9 ++++++++- drupal-core/modules/claude-remote-control/outputs.tf | 2 +- freeform/modules/claude-remote-control/main.tf | 9 ++++++++- freeform/modules/claude-remote-control/outputs.tf | 2 +- modules/claude-remote-control/main.tf | 9 ++++++++- modules/claude-remote-control/outputs.tf | 2 +- 8 files changed, 36 insertions(+), 8 deletions(-) diff --git a/drupal-contrib/modules/claude-remote-control/main.tf b/drupal-contrib/modules/claude-remote-control/main.tf index cf003ca..3e9e3f1 100644 --- a/drupal-contrib/modules/claude-remote-control/main.tf +++ b/drupal-contrib/modules/claude-remote-control/main.tf @@ -36,8 +36,15 @@ locals { enabled = data.coder_parameter.enable_claude_code.value == "true" skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" + + # `claude` is installed via Homebrew (linuxbrew), which is only on PATH in + # interactive shells (~/.bashrc). tmux new-session/respawn-pane run the + # command directly, not through a login/interactive shell, so a bare + # "claude" is not found the first time a session is created at boot - + # invoke it by absolute path instead of depending on PATH inheritance. + claude_bin = "/home/linuxbrew/.linuxbrew/bin/claude" remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, + local.claude_bin, "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, ])) } diff --git a/drupal-contrib/modules/claude-remote-control/outputs.tf b/drupal-contrib/modules/claude-remote-control/outputs.tf index 64ba531..474aaf8 100644 --- a/drupal-contrib/modules/claude-remote-control/outputs.tf +++ b/drupal-contrib/modules/claude-remote-control/outputs.tf @@ -65,7 +65,7 @@ output "startup_script" { ;; esac - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "${local.claude_bin} --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE # ^ must stay at column 0: bash's <<- only strips leading TABS from the # terminator line, not spaces, and this file is space-indented - any diff --git a/drupal-core/modules/claude-remote-control/main.tf b/drupal-core/modules/claude-remote-control/main.tf index cf003ca..3e9e3f1 100644 --- a/drupal-core/modules/claude-remote-control/main.tf +++ b/drupal-core/modules/claude-remote-control/main.tf @@ -36,8 +36,15 @@ locals { enabled = data.coder_parameter.enable_claude_code.value == "true" skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" + + # `claude` is installed via Homebrew (linuxbrew), which is only on PATH in + # interactive shells (~/.bashrc). tmux new-session/respawn-pane run the + # command directly, not through a login/interactive shell, so a bare + # "claude" is not found the first time a session is created at boot - + # invoke it by absolute path instead of depending on PATH inheritance. + claude_bin = "/home/linuxbrew/.linuxbrew/bin/claude" remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, + local.claude_bin, "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, ])) } diff --git a/drupal-core/modules/claude-remote-control/outputs.tf b/drupal-core/modules/claude-remote-control/outputs.tf index 64ba531..474aaf8 100644 --- a/drupal-core/modules/claude-remote-control/outputs.tf +++ b/drupal-core/modules/claude-remote-control/outputs.tf @@ -65,7 +65,7 @@ output "startup_script" { ;; esac - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "${local.claude_bin} --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE # ^ must stay at column 0: bash's <<- only strips leading TABS from the # terminator line, not spaces, and this file is space-indented - any diff --git a/freeform/modules/claude-remote-control/main.tf b/freeform/modules/claude-remote-control/main.tf index cf003ca..3e9e3f1 100644 --- a/freeform/modules/claude-remote-control/main.tf +++ b/freeform/modules/claude-remote-control/main.tf @@ -36,8 +36,15 @@ locals { enabled = data.coder_parameter.enable_claude_code.value == "true" skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" + + # `claude` is installed via Homebrew (linuxbrew), which is only on PATH in + # interactive shells (~/.bashrc). tmux new-session/respawn-pane run the + # command directly, not through a login/interactive shell, so a bare + # "claude" is not found the first time a session is created at boot - + # invoke it by absolute path instead of depending on PATH inheritance. + claude_bin = "/home/linuxbrew/.linuxbrew/bin/claude" remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, + local.claude_bin, "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, ])) } diff --git a/freeform/modules/claude-remote-control/outputs.tf b/freeform/modules/claude-remote-control/outputs.tf index 64ba531..474aaf8 100644 --- a/freeform/modules/claude-remote-control/outputs.tf +++ b/freeform/modules/claude-remote-control/outputs.tf @@ -65,7 +65,7 @@ output "startup_script" { ;; esac - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "${local.claude_bin} --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE # ^ must stay at column 0: bash's <<- only strips leading TABS from the # terminator line, not spaces, and this file is space-indented - any diff --git a/modules/claude-remote-control/main.tf b/modules/claude-remote-control/main.tf index cf003ca..3e9e3f1 100644 --- a/modules/claude-remote-control/main.tf +++ b/modules/claude-remote-control/main.tf @@ -36,8 +36,15 @@ locals { enabled = data.coder_parameter.enable_claude_code.value == "true" skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" + + # `claude` is installed via Homebrew (linuxbrew), which is only on PATH in + # interactive shells (~/.bashrc). tmux new-session/respawn-pane run the + # command directly, not through a login/interactive shell, so a bare + # "claude" is not found the first time a session is created at boot - + # invoke it by absolute path instead of depending on PATH inheritance. + claude_bin = "/home/linuxbrew/.linuxbrew/bin/claude" remote_cmd = join(" ", compact([ - "claude", "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, + local.claude_bin, "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, ])) } diff --git a/modules/claude-remote-control/outputs.tf b/modules/claude-remote-control/outputs.tf index 64ba531..474aaf8 100644 --- a/modules/claude-remote-control/outputs.tf +++ b/modules/claude-remote-control/outputs.tf @@ -65,7 +65,7 @@ output "startup_script" { ;; esac - tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "claude --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "${local.claude_bin} --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" CLAUDEHERE # ^ must stay at column 0: bash's <<- only strips leading TABS from the # terminator line, not spaces, and this file is space-indented - any