Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions registry/coder/modules/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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).
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
91 changes: 87 additions & 4 deletions registry/coder/modules/claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
return TEST_TASK_SESSION_ID;
};

setDefaultTimeout(60 * 1000);

describe("claude-code", async () => {
Expand Down Expand Up @@ -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, [
Expand Down Expand Up @@ -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]);

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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();
});
});
14 changes: 14 additions & 0 deletions registry/coder/modules/claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
46 changes: 46 additions & 0 deletions registry/coder/modules/claude-code/scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 "--------------------------------"

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading