@alanbem/dclaude 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +53 -0
  2. package/dclaude +597 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -151,6 +151,21 @@ dclaude chrome # Launch Chrome with DevTools
151
151
  dclaude # Claude can now interact with the browser
152
152
  ```
153
153
 
154
+ ### iTerm2 Shell Integration
155
+
156
+ If you use iTerm2 on macOS, dclaude automatically enables [iTerm2 Shell Integration](https://iterm2.com/documentation-shell-integration.html):
157
+
158
+ - **Click URLs in output** - Opens in your Mac's browser
159
+ - **imgcat** - Display images inline in terminal
160
+ - **it2copy** - Copy to Mac clipboard from inside container
161
+ - **Marks** - Navigate between command prompts
162
+
163
+ This only activates when running in iTerm2. To disable:
164
+
165
+ ```bash
166
+ DCLAUDE_ITERM2=false dclaude
167
+ ```
168
+
154
169
  ### System Context
155
170
 
156
171
  dclaude automatically tells Claude about its container environment so it can give better suggestions:
@@ -175,10 +190,12 @@ DCLAUDE_SYSTEM_CONTEXT=false dclaude
175
190
  | `DCLAUDE_NAMESPACE` | (none) | Namespace for isolated credentials/config |
176
191
  | `DCLAUDE_NETWORK` | `auto` | Network mode: `auto`, `host`, `bridge` |
177
192
  | `DCLAUDE_GIT_AUTH` | `auto` | SSH auth: `auto`, `agent-forwarding`, `key-mount`, `none` |
193
+ | `DCLAUDE_MOUNT_ROOT` | (working dir) | Mount parent directory for sibling access |
178
194
  | `DCLAUDE_DEBUG` | `false` | Enable debug output |
179
195
  | `DCLAUDE_QUIET` | `false` | Suppress info messages |
180
196
  | `DCLAUDE_NO_UPDATE` | `false` | Skip image update check |
181
197
  | `DCLAUDE_SYSTEM_CONTEXT` | `true` | Inform Claude about container environment |
198
+ | `DCLAUDE_ITERM2` | `true` | Enable iTerm2 shell integration (only affects iTerm2) |
182
199
 
183
200
  ## Configuration File
184
201
 
@@ -200,6 +217,7 @@ dclaude walks up the directory tree to find `.dclaude` files. Any dclaude sessio
200
217
  | `NAMESPACE` | Isolate credentials/config (see Namespace Isolation) |
201
218
  | `NETWORK` | Network mode (`host`, `bridge`) |
202
219
  | `GIT_AUTH` | Git auth mode |
220
+ | `MOUNT_ROOT` | Mount directory (relative to config file, or absolute path) |
203
221
  | `DEBUG` | Enable debug output (`true`, `false`) |
204
222
  | `CHROME_PORT` | Chrome DevTools port |
205
223
 
@@ -238,6 +256,41 @@ Each namespace gets its own:
238
256
  - Git configuration
239
257
  - Container instance
240
258
 
259
+ ## Mount Root (Parent Directory Access)
260
+
261
+ By default, dclaude only mounts the current working directory. Use `MOUNT_ROOT` to mount a parent directory, enabling access to sibling directories.
262
+
263
+ **Use case:** You're working in a subdirectory but need access to related projects:
264
+
265
+ ```text
266
+ /Users/alan/projects/mycompany/
267
+ ├── shared-libs/ # Common libraries
268
+ ├── api-service/ # API backend
269
+ ├── web-app/ # Frontend
270
+ └── infrastructure/
271
+ └── terraform/ # ← You're here, but need access to siblings
272
+ ```
273
+
274
+ **Option 1: Using `.dclaude` file (recommended)**
275
+ ```bash
276
+ # Create config at the directory you want as mount root
277
+ echo "MOUNT_ROOT=." > ~/projects/mycompany/.dclaude
278
+
279
+ # Relative paths are resolved from config file's directory
280
+ echo "MOUNT_ROOT=.." > ~/projects/mycompany/subdir/.dclaude # mounts mycompany/
281
+ ```
282
+
283
+ **Option 2: Using environment variable**
284
+ ```bash
285
+ # Relative path (from working directory)
286
+ DCLAUDE_MOUNT_ROOT=../.. dclaude
287
+
288
+ # Absolute path
289
+ DCLAUDE_MOUNT_ROOT=/Users/alan/projects/mycompany dclaude
290
+ ```
291
+
292
+ Now Claude can see and work with all sibling directories while your working directory remains `terraform/`.
293
+
241
294
  ## Networking
242
295
 
243
296
  dclaude auto-detects the best networking mode:
package/dclaude CHANGED
@@ -27,6 +27,7 @@ CHROME_FLAGS="${DCLAUDE_CHROME_FLAGS:-}"
27
27
  # - DCLAUDE_GIT_AUTH (git auth mode)
28
28
  # - DCLAUDE_DEBUG (enable debug output)
29
29
  # - DCLAUDE_CHROME_PORT (chrome devtools port)
30
+ # - DCLAUDE_MOUNT_ROOT (mount a parent directory instead of working directory)
30
31
 
31
32
  # Colors for output (only if terminal supports it)
32
33
  if [[ -t 1 ]]; then
@@ -122,6 +123,18 @@ load_config_file() {
122
123
  CHROME_PORT)
123
124
  [[ -z "${DCLAUDE_CHROME_PORT:-}" ]] && DCLAUDE_CHROME_PORT="$value"
124
125
  ;;
126
+ MOUNT_ROOT)
127
+ if [[ -z "${DCLAUDE_MOUNT_ROOT:-}" ]]; then
128
+ # Resolve relative paths relative to config file's directory
129
+ if [[ "$value" != /* ]]; then
130
+ local config_dir
131
+ config_dir=$(dirname "$config_file")
132
+ DCLAUDE_MOUNT_ROOT=$(cd "$config_dir" && cd "$value" 2>/dev/null && pwd)
133
+ else
134
+ DCLAUDE_MOUNT_ROOT="$value"
135
+ fi
136
+ fi
137
+ ;;
125
138
  esac
126
139
  fi
127
140
  done < "$config_file"
@@ -147,6 +160,7 @@ if [[ -n "$DCLAUDE_CONFIG_FILE" ]]; then
147
160
  [[ -n "${DCLAUDE_NETWORK:-}" ]] && debug " NETWORK=$DCLAUDE_NETWORK"
148
161
  [[ -n "${DCLAUDE_GIT_AUTH:-}" ]] && debug " GIT_AUTH=$DCLAUDE_GIT_AUTH"
149
162
  [[ -n "${DCLAUDE_CHROME_PORT:-}" ]] && debug " CHROME_PORT=$DCLAUDE_CHROME_PORT"
163
+ [[ -n "${DCLAUDE_MOUNT_ROOT:-}" ]] && debug " MOUNT_ROOT=$DCLAUDE_MOUNT_ROOT"
150
164
  fi
151
165
  fi
152
166
 
@@ -577,6 +591,55 @@ get_host_path() {
577
591
  echo "$host_path"
578
592
  }
579
593
 
594
+ # Compute mount root path from DCLAUDE_MOUNT_ROOT
595
+ # Supports: absolute paths, relative paths (../, ../../, etc.)
596
+ # Returns: Absolute path to mount root, or HOST_PATH if not set
597
+ get_mount_root() {
598
+ local host_path="$1"
599
+ local mount_root="${DCLAUDE_MOUNT_ROOT:-}"
600
+
601
+ # If not set, use host path (current behavior)
602
+ if [[ -z "$mount_root" ]]; then
603
+ echo "$host_path"
604
+ return 0
605
+ fi
606
+
607
+ local resolved_root
608
+
609
+ # Handle relative paths (resolve relative to host_path)
610
+ if [[ "$mount_root" != /* ]]; then
611
+ # Relative path - resolve it
612
+ resolved_root=$(cd "$host_path" && cd "$mount_root" 2>/dev/null && pwd) || {
613
+ error "Mount root path not accessible: $mount_root (relative to $host_path)"
614
+ exit 1
615
+ }
616
+ else
617
+ # Absolute path - use directly
618
+ resolved_root="$mount_root"
619
+ fi
620
+
621
+ # Validate mount root exists
622
+ if [[ ! -d "$resolved_root" ]]; then
623
+ error "Mount root directory does not exist: $resolved_root"
624
+ exit 1
625
+ fi
626
+
627
+ # Validate that host_path is under mount_root
628
+ # Use realpath to normalize paths for comparison
629
+ local real_host real_root
630
+ real_host=$(cd "$host_path" && pwd -P)
631
+ real_root=$(cd "$resolved_root" && pwd -P)
632
+
633
+ if [[ "$real_host" != "$real_root" && "$real_host" != "$real_root"/* ]]; then
634
+ error "Working directory is not under mount root"
635
+ error " Working directory: $real_host"
636
+ error " Mount root: $real_root"
637
+ exit 1
638
+ fi
639
+
640
+ echo "$resolved_root"
641
+ }
642
+
580
643
  # Generate deterministic container name from path (and namespace if set)
581
644
  get_container_name() {
582
645
  local path="${1:-$HOST_PATH}"
@@ -924,6 +987,423 @@ handle_git_auth() {
924
987
  fi
925
988
  }
926
989
 
990
+ # ============================================================================
991
+ # Agent Teams Tmux Passthrough — Relay Functions
992
+ # ============================================================================
993
+ # When the user runs dclaude from inside a host tmux session, these functions
994
+ # set up a TCP relay that forwards Claude Code's tmux commands to the host.
995
+ # This makes Agent Teams sub-agent panes visible in the host's tmux session.
996
+
997
+ # Check if user is in a host tmux session
998
+ detect_host_tmux() {
999
+ [[ -n "${TMUX:-}" ]]
1000
+ }
1001
+
1002
+ # Check if host has required tools for relay (jq + socat or ncat)
1003
+ check_relay_deps() {
1004
+ if ! command -v jq &>/dev/null; then
1005
+ debug "Relay dep missing: jq"
1006
+ return 1
1007
+ fi
1008
+ if command -v socat &>/dev/null; then
1009
+ debug "Relay listener: socat"
1010
+ return 0
1011
+ fi
1012
+ if command -v ncat &>/dev/null; then
1013
+ debug "Relay listener: ncat"
1014
+ return 0
1015
+ fi
1016
+ debug "Relay dep missing: socat or ncat"
1017
+ return 1
1018
+ }
1019
+
1020
+ # Get the address the container uses to reach the host relay
1021
+ get_relay_host() {
1022
+ local platform="$1"
1023
+ local network_mode="$2"
1024
+ # Linux host mode: container shares host network, use loopback
1025
+ if [[ "$platform" == "linux" && "$network_mode" == "host" ]]; then
1026
+ echo "127.0.0.1"
1027
+ else
1028
+ # macOS (any mode) or Linux bridge: use Docker's host gateway
1029
+ echo "host.docker.internal"
1030
+ fi
1031
+ }
1032
+
1033
+ # Get the address the relay binds to on the host
1034
+ get_relay_bind_addr() {
1035
+ local platform="$1"
1036
+ local network_mode="$2"
1037
+ # Linux bridge: container reaches host via bridge gateway, not loopback
1038
+ if [[ "$platform" == "linux" && "$network_mode" == "bridge" ]]; then
1039
+ echo "0.0.0.0"
1040
+ else
1041
+ echo "127.0.0.1"
1042
+ fi
1043
+ }
1044
+
1045
+ # Get or create relay nonce for a container (reused across reattachments)
1046
+ get_or_create_relay_nonce() {
1047
+ local container_name="$1"
1048
+ local nonce_file="$HOME/.dclaude/tmux-relay-nonce-${container_name}"
1049
+
1050
+ mkdir -p "$HOME/.dclaude"
1051
+
1052
+ if [[ -f "$nonce_file" ]]; then
1053
+ cat "$nonce_file"
1054
+ return 0
1055
+ fi
1056
+
1057
+ local nonce
1058
+ nonce=$(head -c 32 /dev/urandom | base64 | tr -d '=/+' | head -c 32)
1059
+ echo "$nonce" > "$nonce_file"
1060
+ echo "$nonce"
1061
+ }
1062
+
1063
+ # Generate the relay handler script (executed by socat/ncat for each connection)
1064
+ # Uses a non-quoted heredoc for baked-in values, then a single-quoted heredoc for logic
1065
+ generate_relay_handler() {
1066
+ local container_name="$1"
1067
+ local relay_port="$2"
1068
+ local nonce="$3"
1069
+ local handler_path="$4"
1070
+ local tracking_file="$5"
1071
+ local relay_host="$6"
1072
+
1073
+ # Header with baked-in values (variable expansion)
1074
+ cat > "$handler_path" <<HANDLER_HEADER
1075
+ #!/bin/bash
1076
+ CONTAINER="$container_name"
1077
+ NONCE="$nonce"
1078
+ RELAY_PORT="$relay_port"
1079
+ RELAY_HOST="$relay_host"
1080
+ TRACKING_FILE="$tracking_file"
1081
+ HANDLER_HEADER
1082
+
1083
+ # Logic body (single-quoted heredoc — no escaping needed)
1084
+ cat >> "$handler_path" <<'HANDLER_BODY'
1085
+
1086
+ # Read one JSON line (max 65536 bytes to prevent memory exhaustion)
1087
+ read -r -n 65536 request
1088
+ if [[ -z "$request" ]]; then
1089
+ printf '{"code":1,"stdout":"","stderr":"empty request"}\n'
1090
+ exit 1
1091
+ fi
1092
+
1093
+ # Validate nonce
1094
+ req_nonce=$(printf '%s' "$request" | jq -r '.nonce // empty')
1095
+ if [[ "$req_nonce" != "$NONCE" ]]; then
1096
+ printf '{"code":1,"stdout":"","stderr":"authentication failed"}\n'
1097
+ exit 1
1098
+ fi
1099
+
1100
+ # Extract command array (bash 3.2 compatible — no mapfile)
1101
+ cmd=()
1102
+ while IFS= read -r line; do
1103
+ cmd+=("$line")
1104
+ done < <(printf '%s' "$request" | jq -r '.cmd[]')
1105
+ subcmd="${cmd[0]}"
1106
+ cwd=$(printf '%s' "$request" | jq -r '.cwd // empty')
1107
+ req_pane=$(printf '%s' "$request" | jq -r '.pane // empty')
1108
+
1109
+ # Allowlist tmux subcommands
1110
+ case "$subcmd" in
1111
+ split-window|send-keys|list-panes|select-layout|resize-pane|\
1112
+ select-pane|kill-pane|has-session|list-windows|display-message|list-sessions)
1113
+ ;;
1114
+ *)
1115
+ printf '{"code":1,"stdout":"","stderr":"disallowed: %s"}\n' "$subcmd"
1116
+ exit 1
1117
+ ;;
1118
+ esac
1119
+
1120
+ # Build tmux args based on subcommand
1121
+ args=()
1122
+
1123
+ if [[ "$subcmd" == "split-window" ]]; then
1124
+ args+=("split-window")
1125
+
1126
+ # Parse split-window flags, strip -c (handled by docker exec -w)
1127
+ # and strip trailing shell command (replaced by docker exec)
1128
+ i=1
1129
+ while (( i < ${#cmd[@]} )); do
1130
+ case "${cmd[$i]}" in
1131
+ -b|-d|-f|-h|-I|-v|-P|-Z)
1132
+ args+=("${cmd[$i]}")
1133
+ ;;
1134
+ -c)
1135
+ # Capture directory, use for docker exec -w
1136
+ (( i++ ))
1137
+ cwd="${cmd[$i]}"
1138
+ ;;
1139
+ -e|-l|-t|-F)
1140
+ # Flags with a value argument
1141
+ args+=("${cmd[$i]}")
1142
+ (( i++ ))
1143
+ args+=("${cmd[$i]}")
1144
+ ;;
1145
+ -*)
1146
+ # Unknown flag, pass through
1147
+ args+=("${cmd[$i]}")
1148
+ ;;
1149
+ *)
1150
+ # Shell command — skip (replaced by docker exec)
1151
+ break
1152
+ ;;
1153
+ esac
1154
+ (( i++ ))
1155
+ done
1156
+
1157
+ # Validate cwd: absolute path, safe characters only
1158
+ if [[ -n "$cwd" && ! "$cwd" =~ ^[a-zA-Z0-9/._-]+$ ]]; then
1159
+ printf '{"code":1,"stdout":"","stderr":"invalid cwd"}\n'
1160
+ exit 1
1161
+ fi
1162
+
1163
+ # Append docker exec as the pane command (no sh -c, args as array)
1164
+ args+=(
1165
+ "--"
1166
+ "docker" "exec" "-it"
1167
+ "-e" "DCLAUDE_TMUX_RELAY_PORT=$RELAY_PORT"
1168
+ "-e" "DCLAUDE_TMUX_RELAY_HOST=$RELAY_HOST"
1169
+ "-e" "DCLAUDE_TMUX_RELAY_NONCE=$NONCE"
1170
+ "-e" "DCLAUDE_CONTAINER=$CONTAINER"
1171
+ "-u" "claude"
1172
+ "-w" "${cwd:-/home/claude}"
1173
+ "$CONTAINER"
1174
+ "bash"
1175
+ )
1176
+
1177
+ elif [[ "$subcmd" == "send-keys" ]]; then
1178
+ args+=("send-keys")
1179
+
1180
+ # Validate target pane is one we created
1181
+ for (( i=1; i < ${#cmd[@]}; i++ )); do
1182
+ if [[ "${cmd[$i]}" == "-t" && $(( i + 1 )) -lt ${#cmd[@]} ]]; then
1183
+ target="${cmd[$(( i + 1 ))]}"
1184
+ if [[ -f "$TRACKING_FILE" ]] && ! grep -qxF "$target" "$TRACKING_FILE"; then
1185
+ printf '{"code":1,"stdout":"","stderr":"pane not tracked: %s"}\n' "$target"
1186
+ exit 1
1187
+ fi
1188
+ break
1189
+ fi
1190
+ done
1191
+
1192
+ # Pass through all args
1193
+ for (( i=1; i < ${#cmd[@]}; i++ )); do
1194
+ args+=("${cmd[$i]}")
1195
+ done
1196
+
1197
+ elif [[ "$subcmd" == "kill-pane" ]]; then
1198
+ args+=("kill-pane")
1199
+ for (( i=1; i < ${#cmd[@]}; i++ )); do
1200
+ args+=("${cmd[$i]}")
1201
+ done
1202
+ else
1203
+ # All other allowed commands: pass through args
1204
+ # Inject -t $req_pane for commands that need pane context
1205
+ # (relay runs outside tmux pane context, so tmux needs explicit targeting)
1206
+ args+=("$subcmd")
1207
+ has_target=false
1208
+ for (( i=1; i < ${#cmd[@]}; i++ )); do
1209
+ [[ "${cmd[$i]}" == "-t" ]] && has_target=true
1210
+ [[ "${cmd[$i]}" =~ ^-t.+ ]] && has_target=true
1211
+ done
1212
+ # Only inject -t for commands that accept it (not list-sessions, has-session)
1213
+ case "$subcmd" in
1214
+ display-message|list-panes|list-windows|select-layout|resize-pane|select-pane)
1215
+ if [[ "$has_target" == "false" && -n "$req_pane" ]]; then
1216
+ args+=("-t" "$req_pane")
1217
+ fi
1218
+ ;;
1219
+ esac
1220
+ for (( i=1; i < ${#cmd[@]}; i++ )); do
1221
+ args+=("${cmd[$i]}")
1222
+ done
1223
+ fi
1224
+
1225
+ # Execute tmux command, capture output
1226
+ stdout_file=$(mktemp)
1227
+ stderr_file=$(mktemp)
1228
+ tmux "${args[@]}" >"$stdout_file" 2>"$stderr_file"
1229
+ code=$?
1230
+ stdout=$(cat "$stdout_file")
1231
+ stderr=$(cat "$stderr_file")
1232
+ rm -f "$stdout_file" "$stderr_file"
1233
+
1234
+ # Post-processing for split-window: track new pane ID
1235
+ if [[ "$subcmd" == "split-window" && $code -eq 0 ]]; then
1236
+ pane_id=$(echo "$stdout" | head -1 | tr -d '[:space:]')
1237
+ if [[ "$pane_id" =~ ^%[0-9]+$ ]]; then
1238
+ echo "$pane_id" >> "$TRACKING_FILE"
1239
+ fi
1240
+ fi
1241
+
1242
+ # Post-processing for kill-pane: clean up orphaned processes after delay
1243
+ if [[ "$subcmd" == "kill-pane" && $code -eq 0 ]]; then
1244
+ (
1245
+ sleep 2
1246
+ docker exec "$CONTAINER" bash -c '
1247
+ ps -eo pid,ppid,tty,comm --no-headers 2>/dev/null | while read pid ppid tty comm; do
1248
+ [[ "$ppid" == "0" && "$comm" == "bash" ]] || continue
1249
+ [[ "$tty" =~ ^pts/([0-9]+)$ ]] || continue
1250
+ (( ${BASH_REMATCH[1]} >= 2 )) || continue
1251
+ children=$(ps --ppid "$pid" --no-headers 2>/dev/null | wc -l)
1252
+ if [[ "$children" -eq 0 ]]; then
1253
+ kill -9 "$pid" 2>/dev/null
1254
+ fi
1255
+ done
1256
+ '
1257
+ ) &>/dev/null &
1258
+ fi
1259
+
1260
+ # Return JSON response
1261
+ jq -nc --arg code "$code" --arg stdout "$stdout" --arg stderr "$stderr" \
1262
+ '{code: ($code | tonumber), stdout: $stdout, stderr: $stderr}'
1263
+ HANDLER_BODY
1264
+ }
1265
+
1266
+ # Start the relay listener on the host
1267
+ start_relay() {
1268
+ local container_name="$1"
1269
+ local relay_port="$2"
1270
+ local nonce="$3"
1271
+ local bind_addr="$4"
1272
+ local relay_host="$5"
1273
+
1274
+ local handler_path="$HOME/.dclaude/tmux-relay-handler-${container_name}.sh"
1275
+ local pid_file="$HOME/.dclaude/tmux-relay-pid-${container_name}"
1276
+ local tracking_file="$HOME/.dclaude/tmux-relay-panes-${container_name}"
1277
+
1278
+ mkdir -p "$HOME/.dclaude"
1279
+
1280
+ # Generate handler script
1281
+ generate_relay_handler "$container_name" "$relay_port" "$nonce" \
1282
+ "$handler_path" "$tracking_file" "$relay_host"
1283
+ chmod +x "$handler_path"
1284
+
1285
+ # Clear pane tracking
1286
+ > "$tracking_file"
1287
+
1288
+ # Start listener
1289
+ if command -v socat &>/dev/null; then
1290
+ debug "Starting socat relay on $bind_addr:$relay_port"
1291
+ socat "TCP-LISTEN:$relay_port,bind=$bind_addr,reuseaddr,fork,max-children=10" \
1292
+ "EXEC:bash $handler_path" 2>/dev/null &
1293
+ elif command -v ncat &>/dev/null; then
1294
+ debug "Starting ncat relay on $bind_addr:$relay_port"
1295
+ ncat -l -k "$bind_addr" "$relay_port" --sh-exec "$handler_path" &
1296
+ else
1297
+ error "No relay listener available (need socat or ncat)"
1298
+ return 1
1299
+ fi
1300
+
1301
+ echo $! > "$pid_file"
1302
+ debug "Relay PID: $(cat "$pid_file")"
1303
+ }
1304
+
1305
+ # Stop the relay and clean up
1306
+ stop_relay() {
1307
+ local container_name="$1"
1308
+
1309
+ local pid_file="$HOME/.dclaude/tmux-relay-pid-${container_name}"
1310
+ local handler_path="$HOME/.dclaude/tmux-relay-handler-${container_name}.sh"
1311
+ local tracking_file="$HOME/.dclaude/tmux-relay-panes-${container_name}"
1312
+
1313
+ if [[ -f "$pid_file" ]]; then
1314
+ local pid
1315
+ pid=$(cat "$pid_file")
1316
+ if [[ -n "$pid" ]]; then
1317
+ # Kill forked handler processes first
1318
+ pkill -P "$pid" 2>/dev/null || true
1319
+ # Verify process identity before killing (guard against PID recycling)
1320
+ local comm
1321
+ comm=$(ps -p "$pid" -o comm= 2>/dev/null || true)
1322
+ if [[ "$comm" =~ (socat|ncat) ]]; then
1323
+ kill "$pid" 2>/dev/null || true
1324
+ fi
1325
+ fi
1326
+ fi
1327
+
1328
+ # Clean up orphaned bash processes in container
1329
+ cleanup_container_orphans "$container_name"
1330
+
1331
+ # Remove relay files (but keep nonce for reattachment)
1332
+ rm -f "$pid_file" "$handler_path" "$tracking_file"
1333
+ }
1334
+
1335
+ # Kill orphaned bash sessions in container (from dead docker exec panes)
1336
+ cleanup_container_orphans() {
1337
+ local container_name="$1"
1338
+
1339
+ # Check container is running before trying
1340
+ if ! docker ps -q -f name="^${container_name}$" 2>/dev/null | grep -q .; then
1341
+ return 0
1342
+ fi
1343
+
1344
+ docker exec "$container_name" bash -c '
1345
+ ps -eo pid,ppid,tty,comm --no-headers 2>/dev/null | while read pid ppid tty comm; do
1346
+ [[ "$ppid" == "0" && "$comm" == "bash" ]] || continue
1347
+ [[ "$tty" =~ ^pts/([0-9]+)$ ]] || continue
1348
+ (( ${BASH_REMATCH[1]} >= 2 )) || continue
1349
+ children=$(ps --ppid "$pid" --no-headers 2>/dev/null | wc -l)
1350
+ if [[ "$children" -eq 0 ]]; then
1351
+ kill -9 "$pid" 2>/dev/null
1352
+ fi
1353
+ done
1354
+ ' 2>/dev/null || true
1355
+ }
1356
+
1357
+ # Set up Agent Teams relay if conditions are met
1358
+ # Sets globals: RELAY_STARTED, TMUX_SESSION_ENV_ARGS
1359
+ setup_agent_teams_relay() {
1360
+ local container_name="$1"
1361
+ local relay_port="$2"
1362
+ local platform="$3"
1363
+ local network_mode="$4"
1364
+
1365
+ RELAY_STARTED=false
1366
+ TMUX_SESSION_ENV_ARGS=()
1367
+
1368
+ if [[ -z "$relay_port" ]]; then
1369
+ debug "No relay port configured for this container"
1370
+ TMUX_SESSION_ENV_ARGS=(-e "DCLAUDE_HIDE_TMUX=1")
1371
+ return
1372
+ fi
1373
+
1374
+ if ! detect_host_tmux; then
1375
+ debug "Not in host tmux - Agent Teams will use in-process mode"
1376
+ TMUX_SESSION_ENV_ARGS=(-e "DCLAUDE_HIDE_TMUX=1")
1377
+ return
1378
+ fi
1379
+
1380
+ if ! check_relay_deps; then
1381
+ info "Tip: Install jq + socat (or ncat) for Agent Teams pane mode in tmux"
1382
+ TMUX_SESSION_ENV_ARGS=(-e "DCLAUDE_HIDE_TMUX=1")
1383
+ return
1384
+ fi
1385
+
1386
+ local bind_addr relay_host relay_nonce host_pane
1387
+ bind_addr=$(get_relay_bind_addr "$platform" "$network_mode")
1388
+ relay_host=$(get_relay_host "$platform" "$network_mode")
1389
+ relay_nonce=$(get_or_create_relay_nonce "$container_name")
1390
+ host_pane=$(tmux display-message -p '#{pane_id}')
1391
+
1392
+ start_relay "$container_name" "$relay_port" "$relay_nonce" "$bind_addr" "$relay_host"
1393
+ RELAY_STARTED=true
1394
+
1395
+ TMUX_SESSION_ENV_ARGS=(
1396
+ -e "DCLAUDE_TMUX_RELAY_PORT=$relay_port"
1397
+ -e "DCLAUDE_TMUX_RELAY_HOST=$relay_host"
1398
+ -e "DCLAUDE_TMUX_RELAY_NONCE=$relay_nonce"
1399
+ -e "DCLAUDE_HOST_PANE=$host_pane"
1400
+ -e "DCLAUDE_CONTAINER=$container_name"
1401
+ )
1402
+
1403
+ debug "Agent Teams relay: $bind_addr:$relay_port -> $container_name (pane $host_pane)"
1404
+ }
1405
+ # ============================================================================
1406
+
927
1407
  # Detect TTY availability and return appropriate Docker flags
928
1408
  # Detect TTY status early (before any subshells)
929
1409
  # Must be done at script top-level since $() subshells don't inherit TTY
@@ -968,6 +1448,17 @@ should_skip_tmux() {
968
1448
  return 1
969
1449
  }
970
1450
 
1451
+ # Debug countdown before launching Claude (gives time to read debug output)
1452
+ # Press Enter to skip the countdown and launch immediately
1453
+ debug_countdown() {
1454
+ if [[ "$DEBUG" == "true" ]]; then
1455
+ for i in 5 4 3 2 1; do
1456
+ printf '\r%b' "${CYAN}Debug: Launching in ${i}... (press Enter to skip)${NC}" >&2
1457
+ read -t 1 -s 2>/dev/null && break
1458
+ done
1459
+ printf '\r%b\n' "${CYAN}Debug: Launching... ${NC}" >&2
1460
+ fi
1461
+ }
971
1462
 
972
1463
  # Main execution
973
1464
  main() {
@@ -976,6 +1467,9 @@ main() {
976
1467
 
977
1468
  info "Verifying environment..."
978
1469
  debug "Host path: $HOST_PATH"
1470
+ if [[ "$MOUNT_ROOT" != "$HOST_PATH" ]]; then
1471
+ debug "Mount root: $MOUNT_ROOT"
1472
+ fi
979
1473
 
980
1474
  # Check prerequisites
981
1475
  check_docker
@@ -1108,6 +1602,9 @@ main() {
1108
1602
  debug " SYSTEM_CONTEXT=$ENABLE_SYSTEM_CONTEXT"
1109
1603
  debug " IMAGE=$IMAGE"
1110
1604
  debug " HOST_PATH=$HOST_PATH"
1605
+ if [[ "$MOUNT_ROOT" != "$HOST_PATH" ]]; then
1606
+ debug " MOUNT_ROOT=$MOUNT_ROOT"
1607
+ fi
1111
1608
  debug " PLATFORM=$platform"
1112
1609
  fi
1113
1610
 
@@ -1145,6 +1642,16 @@ main() {
1145
1642
  done
1146
1643
  fi
1147
1644
 
1645
+ # Ensure SSH proxy is running if container uses agent forwarding (macOS)
1646
+ if [[ "$platform" == "darwin" ]]; then
1647
+ local container_ssh_sock
1648
+ container_ssh_sock=$(docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$container_name" 2>/dev/null | grep "^SSH_AUTH_SOCK=" | cut -d= -f2)
1649
+ if [[ "$container_ssh_sock" == "/tmp/ssh-proxy/agent" ]]; then
1650
+ debug "Container uses SSH agent forwarding, ensuring proxy is running"
1651
+ setup_ssh_proxy_container
1652
+ fi
1653
+ fi
1654
+
1148
1655
  # Check if interactive (TTY available) and not in print mode
1149
1656
  if [[ -n "$tty_flags" ]] && ! should_skip_tmux "${claude_args[@]}" "$@"; then
1150
1657
  # Interactive and not print mode - use tmux for session management
@@ -1163,11 +1670,25 @@ main() {
1163
1670
  [[ -n "${TERM_PROGRAM_VERSION:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}")
1164
1671
  [[ -n "${TERM_SESSION_ID:-}" ]] && exec_env_args+=(-e "TERM_SESSION_ID=${TERM_SESSION_ID}")
1165
1672
  [[ -n "${COLORTERM:-}" ]] && exec_env_args+=(-e "COLORTERM=${COLORTERM}")
1673
+ [[ -n "${DCLAUDE_ITERM2:-}" ]] && exec_env_args+=(-e "DCLAUDE_ITERM2=${DCLAUDE_ITERM2}")
1674
+
1675
+ # Set up Agent Teams relay (or hide $TMUX for in-process mode)
1676
+ local relay_port
1677
+ relay_port=$(docker inspect --format='{{index .Config.Labels "dclaude.relay.port"}}' "$container_name" 2>/dev/null || true)
1678
+ setup_agent_teams_relay "$container_name" "$relay_port" "$platform" "$network_mode"
1679
+ if [[ "$RELAY_STARTED" == "true" ]]; then
1680
+ trap "stop_relay '$container_name'" EXIT
1681
+ fi
1166
1682
 
1167
1683
  debug "Creating new tmux session running Claude"
1168
1684
  debug "Claude args count: ${#claude_args[@]}, user args: $*"
1169
1685
  info "Starting new Claude session..."
1170
- docker exec -it -u claude "${exec_env_args[@]}" "$container_name" tmux -f /home/claude/.tmux.conf new-session -s "$tmux_session" claude "${claude_args[@]}" "$@"
1686
+ debug_countdown
1687
+ docker exec -it -u claude "${exec_env_args[@]}" "$container_name" \
1688
+ /usr/bin/tmux -L dclaude-inner -f /home/claude/.tmux.conf \
1689
+ new-session -s "$tmux_session" \
1690
+ "${TMUX_SESSION_ENV_ARGS[@]}" \
1691
+ claude-launcher.sh "${claude_args[@]}" "$@"
1171
1692
  exit $?
1172
1693
  else
1173
1694
  # Non-interactive or print mode - run claude directly without tmux
@@ -1200,11 +1721,11 @@ main() {
1200
1721
  claude_volume=$(get_volume_name)
1201
1722
 
1202
1723
  DOCKER_ARGS+=(
1203
- # Mount current directory
1204
- -v "${HOST_PATH}:${HOST_PATH}"
1724
+ # Mount directory tree (mount root allows access to parent directories)
1725
+ -v "${MOUNT_ROOT}:${MOUNT_ROOT}"
1205
1726
  # Mount persistent Claude configuration volume
1206
1727
  -v "${claude_volume}:/home/claude/.claude"
1207
- # Set working directory
1728
+ # Set working directory (within the mounted tree)
1208
1729
  -w "${HOST_PATH}"
1209
1730
  # Network mode
1210
1731
  --network="$network_mode"
@@ -1218,6 +1739,17 @@ main() {
1218
1739
  debug "SSH port reserved: $ssh_port"
1219
1740
  DOCKER_ARGS+=(--label "dclaude.ssh.port=${ssh_port}")
1220
1741
 
1742
+ # Reserve relay port for Agent Teams tmux passthrough
1743
+ local relay_port
1744
+ relay_port=$(find_available_port 30000 60000)
1745
+ debug "Relay port reserved: $relay_port"
1746
+ DOCKER_ARGS+=(--label "dclaude.relay.port=${relay_port}")
1747
+
1748
+ # Add host.docker.internal for Linux bridge mode (needed for relay connectivity)
1749
+ if [[ "$platform" == "linux" && "$network_mode" == "bridge" ]]; then
1750
+ DOCKER_ARGS+=(--add-host "host.docker.internal:host-gateway")
1751
+ fi
1752
+
1221
1753
  # Port mapping only needed for bridge mode (host mode shares network stack)
1222
1754
  if [[ "$network_mode" != "host" ]]; then
1223
1755
  DOCKER_ARGS+=(-p "${ssh_port}:${ssh_port}")
@@ -1240,6 +1772,11 @@ main() {
1240
1772
  DOCKER_ARGS+=(-e "COLORTERM=${COLORTERM}")
1241
1773
  fi
1242
1774
 
1775
+ # Pass through iTerm2 integration opt-out if set
1776
+ if [[ -n "${DCLAUDE_ITERM2:-}" ]]; then
1777
+ DOCKER_ARGS+=(-e "DCLAUDE_ITERM2=${DCLAUDE_ITERM2}")
1778
+ fi
1779
+
1243
1780
  # Mount Docker socket if detected (detection done earlier for system context)
1244
1781
  if [[ -n "$DOCKER_SOCKET" ]] && [[ -S "$DOCKER_SOCKET" ]]; then
1245
1782
  DOCKER_ARGS+=(-v "${DOCKER_SOCKET}:/var/run/docker.sock")
@@ -1310,11 +1847,23 @@ main() {
1310
1847
  [[ -n "${TERM_PROGRAM_VERSION:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}")
1311
1848
  [[ -n "${TERM_SESSION_ID:-}" ]] && exec_env_args+=(-e "TERM_SESSION_ID=${TERM_SESSION_ID}")
1312
1849
  [[ -n "${COLORTERM:-}" ]] && exec_env_args+=(-e "COLORTERM=${COLORTERM}")
1850
+ [[ -n "${DCLAUDE_ITERM2:-}" ]] && exec_env_args+=(-e "DCLAUDE_ITERM2=${DCLAUDE_ITERM2}")
1851
+
1852
+ # Set up Agent Teams relay (or hide $TMUX for in-process mode)
1853
+ setup_agent_teams_relay "$container_name" "$relay_port" "$platform" "$network_mode"
1854
+ if [[ "$RELAY_STARTED" == "true" ]]; then
1855
+ trap "stop_relay '$container_name'" EXIT
1856
+ fi
1313
1857
 
1314
1858
  debug "Creating new tmux session running Claude"
1315
1859
  debug "Claude args count: ${#claude_args[@]}, user args: $*"
1316
1860
  info "Starting new Claude session..."
1317
- docker exec -it -u claude "${exec_env_args[@]}" "$container_name" tmux -f /home/claude/.tmux.conf new-session -s "$tmux_session" claude "${claude_args[@]}" "$@"
1861
+ debug_countdown
1862
+ docker exec -it -u claude "${exec_env_args[@]}" "$container_name" \
1863
+ /usr/bin/tmux -L dclaude-inner -f /home/claude/.tmux.conf \
1864
+ new-session -s "$tmux_session" \
1865
+ "${TMUX_SESSION_ENV_ARGS[@]}" \
1866
+ claude-launcher.sh "${claude_args[@]}" "$@"
1318
1867
  exit $?
1319
1868
  else
1320
1869
  # Non-interactive or print mode - run claude directly without tmux
@@ -1444,11 +1993,11 @@ cmd_attach() {
1444
1993
  fi
1445
1994
  fi
1446
1995
 
1447
- # Check if session exists
1448
- if ! docker exec -u claude "$container_name" tmux has-session -t "$session_name" 2>/dev/null; then
1996
+ # Check if session exists (use -L dclaude-inner for namespaced socket)
1997
+ if ! docker exec -u claude "$container_name" /usr/bin/tmux -L dclaude-inner has-session -t "$session_name" 2>/dev/null; then
1449
1998
  error "Session '$session_name' not found in container"
1450
1999
  info "Available sessions:"
1451
- docker exec -u claude "$container_name" tmux list-sessions 2>/dev/null || echo " (no sessions running)"
2000
+ docker exec -u claude "$container_name" /usr/bin/tmux -L dclaude-inner list-sessions 2>/dev/null || echo " (no sessions running)"
1452
2001
  exit 1
1453
2002
  fi
1454
2003
 
@@ -1458,10 +2007,24 @@ cmd_attach() {
1458
2007
  [[ -n "${TERM_PROGRAM_VERSION:-}" ]] && exec_env_args+=(-e "TERM_PROGRAM_VERSION=${TERM_PROGRAM_VERSION}")
1459
2008
  [[ -n "${TERM_SESSION_ID:-}" ]] && exec_env_args+=(-e "TERM_SESSION_ID=${TERM_SESSION_ID}")
1460
2009
  [[ -n "${COLORTERM:-}" ]] && exec_env_args+=(-e "COLORTERM=${COLORTERM}")
2010
+ [[ -n "${DCLAUDE_ITERM2:-}" ]] && exec_env_args+=(-e "DCLAUDE_ITERM2=${DCLAUDE_ITERM2}")
2011
+
2012
+ # Restart relay if the session uses it (relay was stopped when previous dclaude exited)
2013
+ local relay_port
2014
+ relay_port=$(docker inspect --format='{{index .Config.Labels "dclaude.relay.port"}}' "$container_name" 2>/dev/null || true)
2015
+ local platform
2016
+ platform=$(detect_platform)
2017
+ local network_mode
2018
+ network_mode=$(docker inspect --format='{{.HostConfig.NetworkMode}}' "$container_name" 2>/dev/null || echo "bridge")
2019
+ setup_agent_teams_relay "$container_name" "$relay_port" "$platform" "$network_mode"
2020
+ if [[ "$RELAY_STARTED" == "true" ]]; then
2021
+ trap "stop_relay '$container_name'" EXIT
2022
+ fi
1461
2023
 
1462
2024
  # Attach to existing session
1463
2025
  info "Attaching to session: $session_name"
1464
- docker exec -it -u claude "${exec_env_args[@]}" "$container_name" tmux -f /home/claude/.tmux.conf attach-session -t "$session_name"
2026
+ docker exec -it -u claude "${exec_env_args[@]}" "$container_name" \
2027
+ /usr/bin/tmux -L dclaude-inner -f /home/claude/.tmux.conf attach-session -t "$session_name"
1465
2028
  exit $?
1466
2029
  }
1467
2030
 
@@ -1514,7 +2077,7 @@ cmd_chrome() {
1514
2077
  fi
1515
2078
 
1516
2079
  # 2. Setup profile directory
1517
- local profile_dir="$HOST_PATH/.dclaude/chrome/profiles/$CHROME_PROFILE"
2080
+ local profile_dir="$HOST_PATH/.dclaude.d/chrome/profiles/$CHROME_PROFILE"
1518
2081
  mkdir -p "$profile_dir"
1519
2082
  debug "Profile directory: $profile_dir"
1520
2083
 
@@ -1683,7 +2246,7 @@ cmd_stop() {
1683
2246
  if [[ "$container_status" == "running" ]]; then
1684
2247
  # Count active tmux sessions
1685
2248
  local session_count
1686
- session_count=$(docker exec -u claude "$container_name" tmux list-sessions 2>/dev/null | wc -l | tr -d '[:space:]') || session_count=0
2249
+ session_count=$(docker exec -u claude "$container_name" /usr/bin/tmux -L dclaude-inner list-sessions 2>/dev/null | wc -l | tr -d '[:space:]') || session_count=0
1687
2250
 
1688
2251
  if [[ "$session_count" =~ ^[0-9]+$ ]] && [[ "$session_count" -gt 0 ]]; then
1689
2252
  info "Stopping container $container_name ($session_count active session(s))..."
@@ -1691,6 +2254,9 @@ cmd_stop() {
1691
2254
  info "Stopping container $container_name..."
1692
2255
  fi
1693
2256
 
2257
+ # Stop relay if running
2258
+ stop_relay "$container_name"
2259
+
1694
2260
  if docker stop "$container_name" >/dev/null; then
1695
2261
  success "Container stopped"
1696
2262
  else
@@ -1869,7 +2435,7 @@ cmd_rm() {
1869
2435
  if [[ "$force" == "true" ]]; then
1870
2436
  # Count active tmux sessions
1871
2437
  local session_count
1872
- session_count=$(docker exec -u claude "$container_name" tmux list-sessions 2>/dev/null | wc -l | tr -d '[:space:]') || session_count=0
2438
+ session_count=$(docker exec -u claude "$container_name" /usr/bin/tmux -L dclaude-inner list-sessions 2>/dev/null | wc -l | tr -d '[:space:]') || session_count=0
1873
2439
 
1874
2440
  if [[ "$session_count" =~ ^[0-9]+$ ]] && [[ "$session_count" -gt 0 ]]; then
1875
2441
  info "Removing running container $container_name ($session_count active session(s))..."
@@ -1877,6 +2443,10 @@ cmd_rm() {
1877
2443
  info "Removing running container $container_name..."
1878
2444
  fi
1879
2445
 
2446
+ # Clean up relay (including nonce file since container is being removed)
2447
+ stop_relay "$container_name"
2448
+ rm -f "$HOME/.dclaude/tmux-relay-nonce-${container_name}"
2449
+
1880
2450
  if docker rm -f "$container_name" >/dev/null; then
1881
2451
  success "Container removed"
1882
2452
  else
@@ -1891,6 +2461,11 @@ cmd_rm() {
1891
2461
  fi
1892
2462
  else
1893
2463
  info "Removing container $container_name..."
2464
+
2465
+ # Clean up relay files (including nonce since container is being removed)
2466
+ stop_relay "$container_name"
2467
+ rm -f "$HOME/.dclaude/tmux-relay-nonce-${container_name}"
2468
+
1894
2469
  if docker rm "$container_name" >/dev/null; then
1895
2470
  success "Container removed"
1896
2471
  else
@@ -1989,8 +2564,9 @@ cmd_git() {
1989
2564
  exit 0
1990
2565
  }
1991
2566
 
1992
- # Initialize HOST_PATH once for all commands
2567
+ # Initialize HOST_PATH and MOUNT_ROOT once for all commands
1993
2568
  HOST_PATH=$(get_host_path)
2569
+ MOUNT_ROOT=$(get_mount_root "$HOST_PATH")
1994
2570
 
1995
2571
  # Handle subcommands
1996
2572
  if [[ $# -gt 0 ]]; then
@@ -2067,6 +2643,7 @@ Environment Variables:
2067
2643
  DCLAUDE_NAMESPACE Namespace for isolated credentials/config
2068
2644
  DCLAUDE_GIT_AUTH SSH auth for Git: auto, agent-forwarding, key-mount, none
2069
2645
  DCLAUDE_NETWORK Network mode: auto, host, bridge
2646
+ DCLAUDE_MOUNT_ROOT Mount parent directory (absolute or relative path)
2070
2647
  DCLAUDE_DOCKER_SOCKET Override Docker socket path
2071
2648
  DCLAUDE_TMUX_SESSION Custom tmux session name (default: claude-TIMESTAMP)
2072
2649
  DCLAUDE_CHROME_BIN Chrome executable path (auto-detected if not set)
@@ -2079,6 +2656,8 @@ Configuration File (.dclaude):
2079
2656
  NAMESPACE=mycompany
2080
2657
  NETWORK=host
2081
2658
  DEBUG=true
2659
+ MOUNT_ROOT=. # Mount config file's directory
2660
+ MOUNT_ROOT=.. # Mount parent of config file's directory
2082
2661
  dclaude walks up the directory tree to find .dclaude files.
2083
2662
  Environment variables override .dclaude settings.
2084
2663
 
@@ -2102,6 +2681,11 @@ Examples:
2102
2681
  DCLAUDE_NAMESPACE=mycompany dclaude
2103
2682
  # Or create .dclaude file: echo "NAMESPACE=mycompany" > ~/projects/mycompany/.dclaude
2104
2683
 
2684
+ # Mount parent directory to access sibling directories
2685
+ DCLAUDE_MOUNT_ROOT=.. dclaude # Relative path
2686
+ DCLAUDE_MOUNT_ROOT=/Users/alan/projects dclaude # Absolute path
2687
+ # Or in .dclaude file: MOUNT_ROOT=..
2688
+
2105
2689
  # Update image and restart container
2106
2690
  dclaude pull # Pull latest image
2107
2691
  dclaude update # Update Claude CLI in container
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alanbem/dclaude",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Dockerized Claude Code CLI launcher with MCP support",
5
5
  "main": "dclaude",
6
6
  "bin": {