diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 48b291bb0..7107347ef 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -36,6 +36,25 @@ module "claude-code" { By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false` +### Session lifecycle + +When task reporting is enabled the module pins Claude Code to a session ID derived from `data.coder_workspace.me.id` (UUIDv5). This keeps the conversation stable across restarts of the same workspace while remaining unique per workspace, avoiding the "Session ID already in use" error that can occur when home directories are templated or shared. + +The module also writes a managed settings drop-in at `/etc/claude-code/managed-settings.d/30-coder-lifecycle.json` that: + +- registers a `Stop` hook which touches `~/.claude-module/last-stop` whenever Claude finishes a turn, so template authors can wire workspace autostop or activity tracking off that file's modification time +- sets `cleanupPeriodDays` when `transcript_retention_days` is provided, so session JSONL transcripts are pruned automatically + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "4.9.3" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + transcript_retention_days = 7 +} +``` + ## State Persistence AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning). @@ -60,7 +79,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_boundary = true @@ -81,7 +100,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_aibridge = true @@ -110,7 +129,7 @@ data "coder_task" "me" {} module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" ai_prompt = data.coder_task.me.prompt @@ -133,7 +152,7 @@ This example shows additional configuration options for version pinning, custom ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -189,7 +208,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -211,7 +230,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -284,7 +303,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -341,7 +360,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index b01e88327..7f14ba9de 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -66,6 +66,19 @@ const setup = async ( return { id, coderEnvVars }; }; +// start.sh derives TASK_SESSION_ID as uuid5(NAMESPACE_URL, "coder-workspace://" + workspace_id). +// The coder provider populates data.coder_workspace.me.id from CODER_WORKSPACE_ID, +// generating a random value per terraform-apply when unset. Pin it so the +// expected session ID is stable across hosts and runs. +const TEST_CODER_WORKSPACE_ID = "e3aee544-5dbb-4c97-846c-ee9e50a6a06f"; +process.env.CODER_WORKSPACE_ID = TEST_CODER_WORKSPACE_ID; +// uuid5(NAMESPACE_URL, "coder-workspace://" + TEST_CODER_WORKSPACE_ID) +const TEST_TASK_SESSION_ID = "feac99e4-b036-54e7-8ecb-b12e95960344"; + +const deriveTaskSessionId = async (_id: string): Promise => { + return TEST_TASK_SESSION_ID; +}; + setDefaultTimeout(60 * 1000); describe("claude-code", async () => { @@ -222,9 +235,8 @@ describe("claude-code", async () => { }, }); - // Create a mock task session file with the hardcoded task session ID // Note: Claude CLI creates files without "session-" prefix when using --session-id - const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2"; + const taskSessionId = await deriveTaskSessionId(id); const sessionDir = `/home/coder/.claude/projects/-home-coder-project`; await execContainer(id, ["mkdir", "-p", sessionDir]); await execContainer(id, [ @@ -353,7 +365,7 @@ SESSIONEOF`, }, }); - const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2"; + const taskSessionId = await deriveTaskSessionId(id); const sessionDir = `/home/coder/.claude/projects/-home-coder-project`; await execContainer(id, ["mkdir", "-p", sessionDir]); @@ -374,6 +386,10 @@ SESSIONEOF`, // Should start new session, not try to resume invalid one expect(startLog.stdout).toContain("Starting new task session"); expect(startLog.stdout).toContain("--session-id"); + + // Invalid session file should be quarantined, not deleted + const ls = await execContainer(id, ["ls", sessionDir]); + expect(ls.stdout).toContain(`${taskSessionId}.jsonl.bak`); }); test("standalone-first-build-no-sessions", async () => { @@ -442,7 +458,7 @@ EOF`, }, }); - const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2"; + const taskSessionId = await deriveTaskSessionId(id); const sessionDir = `/home/coder/.claude/projects/-home-coder-project`; await execContainer(id, ["mkdir", "-p", sessionDir]); @@ -529,4 +545,71 @@ EOF`, expect(claudeConfig).toContain("typescript-language-server"); expect(claudeConfig).toContain("go-language-server"); }); + + test("task-session-id-derived-from-workspace", async () => { + const { id } = await setup({ + moduleVariables: { + continue: "true", + report_tasks: "true", + ai_prompt: "test prompt", + }, + }); + const expected = await deriveTaskSessionId(id); + + await execModuleScript(id); + + const startLog = await readFileContainer( + id, + "/home/coder/.claude-module/agentapi-start.log", + ); + expect(startLog).toContain(`TASK_SESSION_ID: ${expected}`); + expect(startLog).toContain(`--session-id ${expected}`); + // The legacy hardcoded ID must not be used when a workspace ID is available + if (expected !== "cd32e253-ca16-4fd3-9825-d837e74ae3c2") { + expect(startLog).not.toContain( + "--session-id cd32e253-ca16-4fd3-9825-d837e74ae3c2", + ); + } + }); + + test("lifecycle-settings-written", async () => { + const { id } = await setup({ + moduleVariables: { + transcript_retention_days: "7", + }, + }); + await execModuleScript(id); + + const installLog = await readFileContainer( + id, + "/home/coder/.claude-module/install.log", + ); + expect(installLog).toContain("Wrote lifecycle settings to"); + + const settings = await readFileContainer( + id, + "/etc/claude-code/managed-settings.d/30-coder-lifecycle.json", + ); + const parsed = JSON.parse(settings); + expect(parsed.cleanupPeriodDays).toBe(7); + expect(parsed.hooks.Stop[0].hooks[0].type).toBe("command"); + expect(parsed.hooks.Stop[0].hooks[0].command).toContain("touch"); + expect(parsed.hooks.Stop[0].hooks[0].command).toContain( + "/home/coder/.claude-module/last-stop", + ); + }); + + test("lifecycle-settings-default-retention", async () => { + const { id } = await setup({}); + await execModuleScript(id); + + const settings = await readFileContainer( + id, + "/etc/claude-code/managed-settings.d/30-coder-lifecycle.json", + ); + const parsed = JSON.parse(settings); + // Stop hook is always present; cleanupPeriodDays only when explicitly set + expect(parsed.hooks.Stop).toBeDefined(); + expect(parsed.cleanupPeriodDays).toBeUndefined(); + }); }); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index db234c052..62d961d49 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -273,6 +273,17 @@ variable "enable_state_persistence" { default = true } +variable "transcript_retention_days" { + type = number + description = "Days to keep Claude Code session transcripts before automatic cleanup. Maps to Claude Code's cleanupPeriodDays setting. Defaults to Claude Code's built-in retention (30 days) when unset." + default = null + + validation { + condition = var.transcript_retention_days == null ? true : var.transcript_retention_days >= 1 + error_message = "transcript_retention_days must be at least 1." + } +} + resource "coder_env" "claude_code_md_path" { count = var.claude_md_path == "" ? 0 : 1 agent_id = var.agent_id @@ -407,6 +418,7 @@ module "agentapi" { ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \ ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \ ARG_CODER_HOST='${local.coder_host}' \ + ARG_WORKSPACE_ID='${data.coder_workspace.me.id}' \ ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \ /tmp/start.sh EOT @@ -431,6 +443,8 @@ module "agentapi" { ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \ ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \ ARG_PERMISSION_MODE='${var.permission_mode}' \ + ARG_WORKSPACE_ID='${data.coder_workspace.me.id}' \ + ARG_TRANSCRIPT_RETENTION_DAYS='${var.transcript_retention_days != null ? var.transcript_retention_days : ""}' \ /tmp/install.sh EOT } diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index c00773b5e..32041a0bc 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -23,6 +23,8 @@ ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-} ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-} ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false} ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-} +ARG_WORKSPACE_ID=${ARG_WORKSPACE_ID:-} +ARG_TRANSCRIPT_RETENTION_DAYS=${ARG_TRANSCRIPT_RETENTION_DAYS:-} export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH" @@ -40,6 +42,8 @@ printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH" printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS" printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS" printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE" +printf "ARG_WORKSPACE_ID: %s\n" "$ARG_WORKSPACE_ID" +printf "ARG_TRANSCRIPT_RETENTION_DAYS: %s\n" "$ARG_TRANSCRIPT_RETENTION_DAYS" echo "--------------------------------" @@ -238,6 +242,47 @@ function report_tasks() { fi } +function configure_lifecycle_settings() { + # Write a managed-settings drop-in that: + # - registers a Stop hook touching a sentinel file whose mtime can be + # polled by Coder autostop logic to detect when the agent went idle + # - optionally sets cleanupPeriodDays so transcripts age out + # Managed settings live at /etc/claude-code on Linux and are read by the + # Claude CLI on every backend (Anthropic API, Bedrock, Vertex, gateway). + local module_path="$HOME/.claude-module" + mkdir -p "$module_path" + + local settings_dir="/etc/claude-code/managed-settings.d" + local settings_file="$settings_dir/30-coder-lifecycle.json" + local sentinel="$module_path/last-stop" + + if command_exists sudo; then + SUDO="sudo" + else + SUDO="" + fi + + if ! $SUDO mkdir -p "$settings_dir" 2> /dev/null; then + echo "Warning: cannot create $settings_dir (no write access); skipping lifecycle settings" + return + fi + + local hook_json + hook_json=$( + jq -n --arg sentinel "$sentinel" \ + '{hooks: {Stop: [{hooks: [{type: "command", command: ("touch " + ($sentinel | @sh))}]}]}}' + ) + + local payload="$hook_json" + if [ -n "$ARG_TRANSCRIPT_RETENTION_DAYS" ]; then + payload=$(echo "$hook_json" | jq --argjson days "$ARG_TRANSCRIPT_RETENTION_DAYS" '. + {cleanupPeriodDays: $days}') + fi + + echo "$payload" | $SUDO tee "$settings_file" > /dev/null + $SUDO chmod 0644 "$settings_file" + echo "Wrote lifecycle settings to $settings_file" +} + function accept_auto_mode() { # Pre-accept the auto mode TOS prompt so it doesn't appear interactively. # Claude Code shows a confirmation dialog for auto mode that blocks @@ -258,6 +303,7 @@ function accept_auto_mode() { install_claude_code_cli setup_claude_configurations +configure_lifecycle_settings report_tasks if [ "$ARG_PERMISSION_MODE" = "auto" ]; then diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh index 5ccbc8fa1..b46c40df2 100644 --- a/registry/coder/modules/claude-code/scripts/start.sh +++ b/registry/coder/modules/claude-code/scripts/start.sh @@ -24,6 +24,7 @@ ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"latest"} ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false} ARG_USE_BOUNDARY_DIRECTLY=${ARG_USE_BOUNDARY_DIRECTLY:-false} ARG_CODER_HOST=${ARG_CODER_HOST:-} +ARG_WORKSPACE_ID=${ARG_WORKSPACE_ID:-} echo "--------------------------------" @@ -39,6 +40,7 @@ printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION" printf "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE" printf "ARG_USE_BOUNDARY_DIRECTLY: %s\n" "$ARG_USE_BOUNDARY_DIRECTLY" printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST" +printf "ARG_WORKSPACE_ID: %s\n" "$ARG_WORKSPACE_ID" echo "--------------------------------" @@ -82,9 +84,27 @@ function validate_claude_installation() { fi } -# Hardcoded task session ID for Coder task reporting -# This ensures all task sessions use a consistent, predictable ID -TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2" +# Derive a stable task session ID from the workspace ID so that the Claude +# session is consistent across restarts of the same workspace but unique +# across different workspaces. A globally hardcoded ID causes "Session ID +# already in use" collisions when a home directory is shared or templated +# across workspaces (https://github.com/coder/registry/issues/726). +LEGACY_TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2" +derive_task_session_id() { + if [ -z "$ARG_WORKSPACE_ID" ]; then + printf '%s' "$LEGACY_TASK_SESSION_ID" + return + fi + if command_exists python3; then + python3 -c 'import uuid,sys; print(uuid.uuid5(uuid.NAMESPACE_URL, "coder-workspace://" + sys.argv[1]))' "$ARG_WORKSPACE_ID" + return + fi + # Coder workspace IDs are already RFC 4122 UUIDs, so fall back to using the + # workspace ID directly when python3 is unavailable. + printf '%s' "$ARG_WORKSPACE_ID" +} +TASK_SESSION_ID=$(derive_task_session_id) +printf "TASK_SESSION_ID: %s\n" "$TASK_SESSION_ID" get_project_dir() { local workdir_normalized @@ -120,8 +140,8 @@ is_valid_session() { fi if [ ! -s "$session_file" ]; then - printf "Session validation failed: file is empty, removing stale file\n" - rm -f "$session_file" + printf "Session validation failed: file is empty, quarantining stale file\n" + mv -f "$session_file" "$session_file.bak" return 1 fi @@ -130,16 +150,16 @@ is_valid_session() { local line_count line_count=$(wc -l < "$session_file") if [ "$line_count" -lt 2 ]; then - printf "Session validation failed: incomplete (only %s lines), removing incomplete file\n" "$line_count" - rm -f "$session_file" + printf "Session validation failed: incomplete (only %s lines), quarantining incomplete file\n" "$line_count" + mv -f "$session_file" "$session_file.bak" return 1 fi # Validate JSONL format by checking first 3 lines # Claude session files use JSONL (JSON Lines) format where each line is valid JSON if ! head -3 "$session_file" | jq empty 2> /dev/null; then - printf "Session validation failed: invalid JSONL format, removing corrupt file\n" - rm -f "$session_file" + printf "Session validation failed: invalid JSONL format, quarantining corrupt file\n" + mv -f "$session_file" "$session_file.bak" return 1 fi @@ -147,8 +167,8 @@ is_valid_session() { # This ensures the file structure matches Claude's session format if ! grep -q '"sessionId"' "$session_file" \ || ! grep -m 1 '"sessionId"' "$session_file" | jq -e '.sessionId' > /dev/null 2>&1; then - printf "Session validation failed: no valid sessionId found, removing malformed file\n" - rm -f "$session_file" + printf "Session validation failed: no valid sessionId found, quarantining malformed file\n" + mv -f "$session_file" "$session_file.bak" return 1 fi