augint-shell 0.71.2__tar.gz → 0.72.0__tar.gz

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 (32) hide show
  1. {augint_shell-0.71.2 → augint_shell-0.72.0}/PKG-INFO +10 -8
  2. {augint_shell-0.71.2 → augint_shell-0.72.0}/README.md +9 -7
  3. {augint_shell-0.71.2 → augint_shell-0.72.0}/pyproject.toml +1 -1
  4. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/__init__.py +1 -1
  5. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/cli/commands/tools.py +253 -168
  6. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/config.py +1 -1
  7. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/interactive.py +43 -16
  8. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/local_chrome.py +130 -54
  9. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/tmux.py +1 -1
  10. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/cli/__init__.py +0 -0
  11. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/cli/__main__.py +0 -0
  12. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/cli/commands/__init__.py +0 -0
  13. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/cli/commands/llm.py +0 -0
  14. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/cli/commands/manage.py +0 -0
  15. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/container.py +0 -0
  16. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/defaults.py +0 -0
  17. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/exceptions.py +0 -0
  18. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/gpu.py +0 -0
  19. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/scaffold.py +0 -0
  20. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/selector.py +0 -0
  21. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/__init__.py +0 -0
  22. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/ai-shell.toml +0 -0
  23. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/ai-shell.yaml +0 -0
  24. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/aider/__init__.py +0 -0
  25. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/aider/aider.conf.yml +0 -0
  26. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/aider/aiderignore +0 -0
  27. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/claude/__init__.py +0 -0
  28. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/claude/settings.json +0 -0
  29. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/codex/__init__.py +0 -0
  30. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/codex/config.toml +0 -0
  31. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/opencode/__init__.py +0 -0
  32. {augint_shell-0.71.2 → augint_shell-0.72.0}/src/ai_shell/templates/opencode/opencode.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: augint-shell
3
- Version: 0.71.2
3
+ Version: 0.72.0
4
4
  Summary: Launch AI coding tools and local LLMs in Docker containers
5
5
  Author: svange
6
6
  Requires-Dist: docker>=7.0.0
@@ -132,7 +132,7 @@ The dev container cannot open a browser on the Windows host, which blocks OAuth
132
132
  What you get:
133
133
 
134
134
  - Claude drives Chrome tabs on your Windows desktop, visible in real time.
135
- - Uses a **separate Chrome profile** (`ai-debug-profile`) -- your normal browsing is untouched.
135
+ - Uses a **separate Chrome profile per project** -- your normal browsing is untouched, and each repo keeps its own logged-in state.
136
136
  - No Chrome extension. No third-party service. All traffic stays on `localhost` between the container and the host.
137
137
 
138
138
  ### How it works
@@ -142,9 +142,10 @@ ai-shell claude --local-chrome
142
142
  ```
143
143
 
144
144
  `ai-shell` automatically:
145
- 1. Checks if Chrome is already running with a debug port (port 9222).
146
- 2. If not, **launches Chrome** on a free port with its own profile at `%LOCALAPPDATA%\Google\Chrome\ai-debug-profile`.
147
- 3. Starts a TCP proxy inside the container and injects `chrome-devtools-mcp` as an MCP server for Claude.
145
+ 1. Computes a stable debug port and Chrome profile for the current project.
146
+ 2. Reuses that project's Chrome if it is already running.
147
+ 3. If not, **launches Chrome** for the project on its assigned port with its own profile under `%LOCALAPPDATA%\Google\Chrome\ai-shell\...`.
148
+ 4. Starts a TCP proxy inside the container and injects `chrome-devtools-mcp` as an MCP server for Claude.
148
149
 
149
150
  No manual setup required. Chrome stays open after Claude exits so your login sessions persist. Sign in to whatever accounts you need in that Chrome window (first time only; cookies persist in the profile).
150
151
 
@@ -161,16 +162,17 @@ Or set the environment variable: `AI_SHELL_LOCAL_CHROME=1`.
161
162
 
162
163
  ### Manual Chrome launch (fallback)
163
164
 
164
- If `ai-shell` can't find `chrome.exe` automatically, launch Chrome yourself:
165
+ If `ai-shell` can't find `chrome.exe` automatically, launch Chrome yourself using the project-specific port and profile path that `ai-shell` prints in the error message. The shape of the command is:
165
166
 
166
167
  ```
167
- chrome.exe --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --remote-allow-origins=* --user-data-dir="%LOCALAPPDATA%\Google\Chrome\ai-debug-profile"
168
+ chrome.exe --remote-debugging-port=<project-port> --remote-debugging-address=127.0.0.1 --remote-allow-origins=* --user-data-dir="%LOCALAPPDATA%\Google\Chrome\ai-shell\<project-slug>"
168
169
  ```
169
170
 
170
171
  ### Troubleshooting
171
172
 
172
173
  - **"Chrome could not be found or launched"** -- Chrome is not installed at a standard location. Use the manual launch command above, or set the path in your system PATH.
173
- - **Tabs appear empty / not logged in** -- Sign in to the accounts you need inside the auto-launched Chrome (the one with the `ai-debug-profile` window title). Cookies persist across sessions.
174
+ - **Tabs appear empty / not logged in** -- Sign in to the accounts you need inside the auto-launched Chrome for that project. Cookies persist in that project's profile across sessions.
175
+ - **A different repo opened the wrong Chrome window** -- Each project now gets its own Chrome profile and debug port. Re-run from the correct repo so `ai-shell` attaches to that repo's browser instance.
174
176
  - **Firefox / Safari** -- not supported. `chrome-devtools-mcp` requires a Chromium-based browser. Edge works with the same flags but has not been tested here.
175
177
 
176
178
  ## Standardization architecture
@@ -119,7 +119,7 @@ The dev container cannot open a browser on the Windows host, which blocks OAuth
119
119
  What you get:
120
120
 
121
121
  - Claude drives Chrome tabs on your Windows desktop, visible in real time.
122
- - Uses a **separate Chrome profile** (`ai-debug-profile`) -- your normal browsing is untouched.
122
+ - Uses a **separate Chrome profile per project** -- your normal browsing is untouched, and each repo keeps its own logged-in state.
123
123
  - No Chrome extension. No third-party service. All traffic stays on `localhost` between the container and the host.
124
124
 
125
125
  ### How it works
@@ -129,9 +129,10 @@ ai-shell claude --local-chrome
129
129
  ```
130
130
 
131
131
  `ai-shell` automatically:
132
- 1. Checks if Chrome is already running with a debug port (port 9222).
133
- 2. If not, **launches Chrome** on a free port with its own profile at `%LOCALAPPDATA%\Google\Chrome\ai-debug-profile`.
134
- 3. Starts a TCP proxy inside the container and injects `chrome-devtools-mcp` as an MCP server for Claude.
132
+ 1. Computes a stable debug port and Chrome profile for the current project.
133
+ 2. Reuses that project's Chrome if it is already running.
134
+ 3. If not, **launches Chrome** for the project on its assigned port with its own profile under `%LOCALAPPDATA%\Google\Chrome\ai-shell\...`.
135
+ 4. Starts a TCP proxy inside the container and injects `chrome-devtools-mcp` as an MCP server for Claude.
135
136
 
136
137
  No manual setup required. Chrome stays open after Claude exits so your login sessions persist. Sign in to whatever accounts you need in that Chrome window (first time only; cookies persist in the profile).
137
138
 
@@ -148,16 +149,17 @@ Or set the environment variable: `AI_SHELL_LOCAL_CHROME=1`.
148
149
 
149
150
  ### Manual Chrome launch (fallback)
150
151
 
151
- If `ai-shell` can't find `chrome.exe` automatically, launch Chrome yourself:
152
+ If `ai-shell` can't find `chrome.exe` automatically, launch Chrome yourself using the project-specific port and profile path that `ai-shell` prints in the error message. The shape of the command is:
152
153
 
153
154
  ```
154
- chrome.exe --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --remote-allow-origins=* --user-data-dir="%LOCALAPPDATA%\Google\Chrome\ai-debug-profile"
155
+ chrome.exe --remote-debugging-port=<project-port> --remote-debugging-address=127.0.0.1 --remote-allow-origins=* --user-data-dir="%LOCALAPPDATA%\Google\Chrome\ai-shell\<project-slug>"
155
156
  ```
156
157
 
157
158
  ### Troubleshooting
158
159
 
159
160
  - **"Chrome could not be found or launched"** -- Chrome is not installed at a standard location. Use the manual launch command above, or set the path in your system PATH.
160
- - **Tabs appear empty / not logged in** -- Sign in to the accounts you need inside the auto-launched Chrome (the one with the `ai-debug-profile` window title). Cookies persist across sessions.
161
+ - **Tabs appear empty / not logged in** -- Sign in to the accounts you need inside the auto-launched Chrome for that project. Cookies persist in that project's profile across sessions.
162
+ - **A different repo opened the wrong Chrome window** -- Each project now gets its own Chrome profile and debug port. Re-run from the correct repo so `ai-shell` attaches to that repo's browser instance.
161
163
  - **Firefox / Safari** -- not supported. `chrome-devtools-mcp` requires a Chromium-based browser. Edge works with the same flags but has not been tested here.
162
164
 
163
165
  ## Standardization architecture
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "augint-shell"
3
- version = "0.71.2"
3
+ version = "0.72.0"
4
4
  description = "Launch AI coding tools and local LLMs in Docker containers"
5
5
  authors = [{name = "svange"}]
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  """augint-shell (ai-shell) - Launch AI coding tools and local LLMs in Docker containers."""
2
2
 
3
- __version__ = "0.71.2"
3
+ __version__ = "0.72.0"
4
4
 
5
5
  __all__ = [
6
6
  "__version__",
@@ -34,6 +34,14 @@ def _generate_worktree_name() -> str:
34
34
  return uuid.uuid4().hex[:8]
35
35
 
36
36
 
37
+ def _print_tmux_quick_start() -> None:
38
+ """Print a short tmux quick-start before attaching."""
39
+ console.print("[dim]tmux: mouse click=focus drag=resize wheel=scroll[/dim]")
40
+ console.print(
41
+ "[dim] Ctrl-b o=pane c=tab Space=layout p/n=tab z=zoom d=detach &=kill-tab[/dim]"
42
+ )
43
+
44
+
37
45
  def _setup_worktree(container_name: str, container_project_dir: str, name: str) -> str:
38
46
  """Create a git worktree inside the container and return its absolute container path.
39
47
 
@@ -255,6 +263,130 @@ def _get_manager(
255
263
  return manager, container_name, exec_env, config
256
264
 
257
265
 
266
+ def _bedrock_label(exec_env: dict[str, str]) -> str:
267
+ """Return the user-facing Bedrock suffix for launch messages."""
268
+ profile_label = exec_env.get("AWS_PROFILE", "default")
269
+ region_label = exec_env.get("AWS_REGION", "us-east-1")
270
+ return f" via Bedrock (profile={profile_label}, region={region_label})"
271
+
272
+
273
+ def _configure_local_chrome(
274
+ container_name: str,
275
+ *,
276
+ project_name: str,
277
+ project_dir: Path | str | None,
278
+ ) -> tuple[list[str], str]:
279
+ """Attach chrome-devtools-mcp to a host Chrome instance."""
280
+ from ai_shell.local_chrome import (
281
+ LocalChromeUnavailable,
282
+ ensure_host_chrome,
283
+ start_chrome_proxy,
284
+ write_mcp_config,
285
+ )
286
+
287
+ console.print("[dim]Connecting to host Chrome...[/dim]")
288
+ try:
289
+ chrome_port = ensure_host_chrome(
290
+ container_name,
291
+ project_name=project_name,
292
+ project_dir=project_dir,
293
+ )
294
+ except LocalChromeUnavailable as exc:
295
+ raise click.ClickException(str(exc)) from exc
296
+
297
+ console.print(f"[dim]Chrome debug port {chrome_port} reachable.[/dim]")
298
+
299
+ # Start TCP proxy: localhost:<port> -> host.docker.internal:<port>
300
+ # Chrome rejects non-localhost Host headers, so the MCP server
301
+ # must connect via localhost.
302
+ start_chrome_proxy(container_name, chrome_port)
303
+
304
+ host_mcp_path = write_mcp_config(chrome_port)
305
+ container_mcp_path = "/etc/ai-shell/chrome-mcp.json"
306
+ _inject_mcp_config(container_name, str(host_mcp_path), container_mcp_path)
307
+ console.print("[dim]Chrome DevTools MCP attached.[/dim]")
308
+ return ["--mcp-config", container_mcp_path], container_mcp_path
309
+
310
+
311
+ def _launch_loaded_config_claude(
312
+ config: AiShellConfig,
313
+ *,
314
+ safe: bool,
315
+ use_aws: bool,
316
+ cli_profile: str | None,
317
+ extra_args: tuple[str, ...],
318
+ local_chrome: bool = False,
319
+ team_mode: bool = False,
320
+ worktree_name: str | None = None,
321
+ ) -> None:
322
+ """Launch Claude for an already loaded project config."""
323
+ use_bedrock = use_aws or config.claude_provider == "aws"
324
+ manager = ContainerManager(config)
325
+ container_name = manager.ensure_dev_container()
326
+ exec_env = build_dev_environment(
327
+ config.extra_env,
328
+ config.project_dir,
329
+ project_name=config.project_name,
330
+ bedrock=use_bedrock,
331
+ aws_profile=config.ai_profile,
332
+ aws_region=config.aws_region,
333
+ bedrock_profile=cli_profile or config.bedrock_profile,
334
+ )
335
+
336
+ if team_mode:
337
+ exec_env = dict(exec_env)
338
+ exec_env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
339
+
340
+ bedrock_label = ""
341
+ if use_bedrock:
342
+ bedrock_label = _bedrock_label(exec_env)
343
+ console.print(
344
+ "Checking Bedrock access "
345
+ f"(profile={exec_env.get('AWS_PROFILE', 'default')}, "
346
+ f"region={exec_env.get('AWS_REGION', 'us-east-1')})..."
347
+ )
348
+ _check_bedrock_access(container_name, exec_env)
349
+
350
+ workdir: str | None = None
351
+ resolved_worktree_name = worktree_name
352
+ if resolved_worktree_name is not None:
353
+ if resolved_worktree_name == "":
354
+ resolved_worktree_name = _generate_worktree_name()
355
+ container_project_dir = f"/root/projects/{config.project_name}"
356
+ workdir = _setup_worktree(container_name, container_project_dir, resolved_worktree_name)
357
+ console.print(f"[dim]Worktree: {workdir} (branch: worktree-{resolved_worktree_name})[/dim]")
358
+
359
+ mcp_args: list[str] = []
360
+ if local_chrome:
361
+ mcp_args, _ = _configure_local_chrome(
362
+ container_name,
363
+ project_name=config.project_name,
364
+ project_dir=config.project_dir,
365
+ )
366
+
367
+ if safe:
368
+ cmd = ["claude", *mcp_args, *extra_args]
369
+ console.print(
370
+ f"[bold]Launching Claude Code (safe mode){bedrock_label} in {container_name}...[/bold]"
371
+ )
372
+ manager.exec_interactive(container_name, cmd, extra_env=exec_env, workdir=workdir)
373
+ return
374
+
375
+ cmd_continue = ["claude", "--dangerously-skip-permissions", "-c", *mcp_args, *extra_args]
376
+ console.print(f"[bold]Launching Claude Code{bedrock_label} in {container_name}...[/bold]")
377
+ exit_code, elapsed = manager.run_interactive(
378
+ container_name, cmd_continue, extra_env=exec_env, workdir=workdir
379
+ )
380
+
381
+ if exit_code != 0 and elapsed < FAST_FAILURE_THRESHOLD:
382
+ console.print("[yellow]No prior conversation found, starting fresh...[/yellow]")
383
+ cmd_fresh = ["claude", "--dangerously-skip-permissions", *mcp_args, *extra_args]
384
+ manager.exec_interactive(container_name, cmd_fresh, extra_env=exec_env, workdir=workdir)
385
+ return
386
+
387
+ sys.exit(exit_code)
388
+
389
+
258
390
  def _load_workspace_repos(workspace_yaml: Path) -> tuple[str, list[dict[str, Any]]]:
259
391
  """Parse workspace.yaml and return (workspace_name, repos_list).
260
392
 
@@ -273,20 +405,21 @@ def _load_workspace_repos(workspace_yaml: Path) -> tuple[str, list[dict[str, Any
273
405
  return workspace_name, repos
274
406
 
275
407
 
276
- def _launch_interactive_multi(
408
+ def _launch_interactive(
277
409
  ctx: click.Context,
278
410
  *,
279
411
  safe: bool,
280
412
  use_aws: bool,
281
413
  cli_profile: str | None,
282
414
  extra_args: tuple[str, ...],
415
+ worktree_name: str | None = None,
283
416
  ) -> None:
284
- """Interactive multi-pane launcher.
417
+ """Interactive Claude launcher.
285
418
 
286
- Walks the user through a guided wizard to configure each tmux pane,
287
- then builds and launches the customised tmux session.
419
+ Walks the user through a guided wizard to configure panes, then launches
420
+ either a normal single session or a customised tmux session.
288
421
  """
289
- from ai_shell.interactive import build_interactive_panes, run_interactive_wizard
422
+ from ai_shell.interactive import PaneType, build_interactive_panes, run_interactive_wizard
290
423
  from ai_shell.tmux import (
291
424
  TMUX_SESSION_PREFIX,
292
425
  build_attach_command,
@@ -294,13 +427,83 @@ def _launch_interactive_multi(
294
427
  build_tmux_commands,
295
428
  )
296
429
 
297
- # Load config early -- needed for session check and container setup.
430
+ # Load config early -- needed for prompt defaults and any single-pane launch.
298
431
  project = ctx.obj.get("project") if ctx.obj else None
299
432
  config = load_config(project_override=project, project_dir=Path.cwd())
433
+
434
+ # Gather workspace repos if available.
435
+ workspace_yaml = Path.cwd() / "workspace.yaml"
436
+ workspace_repos = None
437
+ if workspace_yaml.exists():
438
+ _, workspace_repos = _load_workspace_repos(workspace_yaml)
439
+
440
+ # Run the wizard.
441
+ interactive_config = run_interactive_wizard(
442
+ project_name=config.project_name,
443
+ workspace_repos=workspace_repos,
444
+ default_windows=2,
445
+ default_shared_chrome=config.local_chrome is True,
446
+ )
447
+ if interactive_config is None:
448
+ console.print("[dim]Cancelled.[/dim]")
449
+ return
450
+
451
+ if interactive_config.pane_count == 1:
452
+ choice = interactive_config.pane_choices[0]
453
+
454
+ if choice.pane_type == PaneType.BASH:
455
+ manager = ContainerManager(config)
456
+ container_name = manager.ensure_dev_container()
457
+ exec_env = build_dev_environment(
458
+ config.extra_env,
459
+ config.project_dir,
460
+ project_name=config.project_name,
461
+ aws_profile=config.ai_profile,
462
+ aws_region=config.aws_region,
463
+ bedrock_profile=cli_profile or config.bedrock_profile,
464
+ )
465
+ console.print(f"[bold]Launching Bash in {container_name}...[/bold]")
466
+ manager.exec_interactive(
467
+ container_name,
468
+ ["/bin/bash"],
469
+ extra_env=exec_env,
470
+ workdir=f"/root/projects/{config.project_name}",
471
+ )
472
+ return
473
+
474
+ selected_dir = config.project_dir
475
+ target_label = config.project_name
476
+ if choice.pane_type == PaneType.WORKSPACE_REPO:
477
+ selected_dir = Path.cwd() / choice.repo_path
478
+ if not selected_dir.exists():
479
+ raise click.ClickException(
480
+ f"Repo directory not found: {selected_dir}\n"
481
+ " Run /ai-workspace-sync to clone workspace repos first."
482
+ )
483
+ target_label = choice.repo_name
484
+
485
+ console.print(
486
+ f"[dim]Single Claude pane selected -- launching standard session in "
487
+ f"{target_label}[/dim]"
488
+ )
489
+ selected_config = load_config(project_override=project, project_dir=selected_dir)
490
+ _launch_loaded_config_claude(
491
+ selected_config,
492
+ safe=safe,
493
+ use_aws=use_aws,
494
+ cli_profile=cli_profile,
495
+ extra_args=extra_args,
496
+ local_chrome=interactive_config.shared_chrome,
497
+ team_mode=interactive_config.team_mode,
498
+ worktree_name=worktree_name,
499
+ )
500
+ return
501
+
300
502
  session_name = f"{TMUX_SESSION_PREFIX}-{config.project_name}"
301
503
  container_name = dev_container_name(config.project_name, config.project_dir)
302
504
 
303
- # Check for existing tmux session.
505
+ # Check for existing tmux session after the user has actually chosen a
506
+ # multi-pane launch.
304
507
  check_cmd = build_check_session_command(container_name, session_name)
305
508
  has_session = subprocess.run(check_cmd, capture_output=True).returncode == 0
306
509
 
@@ -314,28 +517,13 @@ def _launch_interactive_multi(
314
517
  if choice == "reconnect":
315
518
  attach_cmd = build_attach_command(container_name, session_name)
316
519
  logger.debug("tmux reattach: %s", " ".join(attach_cmd))
520
+ _print_tmux_quick_start()
317
521
  sys.stdout.flush()
318
522
  sys.stderr.flush()
319
523
  attach = subprocess.run(attach_cmd)
320
524
  sys.exit(attach.returncode)
321
- elif choice == "cancel":
525
+ if choice == "cancel":
322
526
  return
323
- # choice == "fresh": fall through to wizard
324
-
325
- # Gather workspace repos if available.
326
- workspace_yaml = Path.cwd() / "workspace.yaml"
327
- workspace_repos = None
328
- if workspace_yaml.exists():
329
- _, workspace_repos = _load_workspace_repos(workspace_yaml)
330
-
331
- # Run the wizard.
332
- interactive_config = run_interactive_wizard(
333
- project_name=config.project_name,
334
- workspace_repos=workspace_repos,
335
- )
336
- if interactive_config is None:
337
- console.print("[dim]Cancelled.[/dim]")
338
- return
339
527
 
340
528
  # Ensure container is running.
341
529
  use_bedrock = use_aws or config.claude_provider == "aws"
@@ -357,25 +545,12 @@ def _launch_interactive_multi(
357
545
  # Handle shared Chrome if requested.
358
546
  mcp_config_path: str | None = None
359
547
  if interactive_config.shared_chrome:
360
- from ai_shell.local_chrome import (
361
- LocalChromeUnavailable,
362
- ensure_host_chrome,
363
- start_chrome_proxy,
364
- write_mcp_config,
548
+ _, mcp_config_path = _configure_local_chrome(
549
+ container_name,
550
+ project_name=config.project_name,
551
+ project_dir=config.project_dir,
365
552
  )
366
553
 
367
- console.print("[dim]Connecting to host Chrome...[/dim]")
368
- try:
369
- chrome_port = ensure_host_chrome(container_name)
370
- except LocalChromeUnavailable as exc:
371
- raise click.ClickException(str(exc)) from exc
372
-
373
- start_chrome_proxy(container_name, chrome_port)
374
- host_mcp_path = write_mcp_config(chrome_port)
375
- mcp_config_path = "/etc/ai-shell/chrome-mcp.json"
376
- _inject_mcp_config(container_name, str(host_mcp_path), mcp_config_path)
377
- console.print("[dim]Chrome DevTools MCP attached.[/dim]")
378
-
379
554
  # Build pane specs.
380
555
  container_project_root = f"/root/projects/{config.project_name}"
381
556
  panes = build_interactive_panes(
@@ -401,6 +576,7 @@ def _launch_interactive_multi(
401
576
 
402
577
  final_cmd = cmds[-1]
403
578
  logger.debug("tmux attach: %s", " ".join(final_cmd))
579
+ _print_tmux_quick_start()
404
580
  sys.stdout.flush()
405
581
  sys.stderr.flush()
406
582
  attach = subprocess.run(final_cmd)
@@ -571,6 +747,7 @@ def _launch_single_repo_multi(
571
747
 
572
748
  final_cmd = cmds[-1]
573
749
  logger.debug("tmux attach: %s", " ".join(final_cmd))
750
+ _print_tmux_quick_start()
574
751
  sys.stdout.flush()
575
752
  sys.stderr.flush()
576
753
  attach = subprocess.run(final_cmd)
@@ -628,6 +805,7 @@ def _launch_multi(
628
805
  if choice == "reconnect":
629
806
  attach_cmd = build_attach_command(container_name, session_name)
630
807
  logger.debug("tmux reattach: %s", " ".join(attach_cmd))
808
+ _print_tmux_quick_start()
631
809
  sys.stdout.flush()
632
810
  sys.stderr.flush()
633
811
  attach = subprocess.run(attach_cmd)
@@ -686,41 +864,16 @@ def _launch_multi(
686
864
  " Run /ai-workspace-sync to clone workspace repos first."
687
865
  )
688
866
  console.print(f"[dim]Single selection -- launching Claude in {sel.label}[/dim]")
689
- # Re-derive config from the selected repo's directory
690
867
  project = ctx.obj.get("project") if ctx.obj else None
691
868
  config = load_config(project_override=project, project_dir=sel_path)
692
- use_bedrock = use_aws or config.claude_provider == "aws"
693
- manager = ContainerManager(config)
694
- container_name = manager.ensure_dev_container()
695
- exec_env = build_dev_environment(
696
- config.extra_env,
697
- config.project_dir,
698
- project_name=config.project_name,
699
- bedrock=use_bedrock,
700
- aws_profile=config.ai_profile,
701
- aws_region=config.aws_region,
702
- bedrock_profile=cli_profile or config.bedrock_profile,
869
+ _launch_loaded_config_claude(
870
+ config,
871
+ safe=safe,
872
+ use_aws=use_aws,
873
+ cli_profile=cli_profile,
874
+ extra_args=extra_args,
875
+ worktree_name=worktree_name,
703
876
  )
704
-
705
- if use_bedrock:
706
- _check_bedrock_access(container_name, exec_env)
707
-
708
- workdir = f"/root/projects/{config.project_name}"
709
- if safe:
710
- cmd = ["claude", *extra_args]
711
- manager.exec_interactive(container_name, cmd, extra_env=exec_env, workdir=workdir)
712
- else:
713
- cmd_c = ["claude", "--dangerously-skip-permissions", "-c", *extra_args]
714
- exit_code, elapsed = manager.run_interactive(
715
- container_name, cmd_c, extra_env=exec_env, workdir=workdir
716
- )
717
- if exit_code != 0 and elapsed < FAST_FAILURE_THRESHOLD:
718
- cmd_fresh = ["claude", "--dangerously-skip-permissions", *extra_args]
719
- manager.exec_interactive(
720
- container_name, cmd_fresh, extra_env=exec_env, workdir=workdir
721
- )
722
- else:
723
- sys.exit(exit_code)
724
877
  return
725
878
 
726
879
  # Multi selection (2-4 repos): validate dirs, build tmux session
@@ -801,6 +954,7 @@ def _launch_multi(
801
954
  # Final command: interactive attach (replaces process)
802
955
  final_cmd = cmds[-1]
803
956
  logger.debug("tmux attach: %s", " ".join(final_cmd))
957
+ _print_tmux_quick_start()
804
958
  sys.stdout.flush()
805
959
  sys.stderr.flush()
806
960
  attach = subprocess.run(final_cmd)
@@ -852,9 +1006,9 @@ def _launch_multi(
852
1006
  is_flag=True,
853
1007
  default=False,
854
1008
  help=(
855
- "Attach Chrome DevTools MCP to host Chrome running with "
856
- "--remote-debugging-port=9222. Gives Claude browser control "
857
- "over your real Windows Chrome tabs."
1009
+ "Attach Chrome DevTools MCP to a project-scoped host Chrome session. "
1010
+ "ai-shell launches or reuses a separate Windows Chrome profile for "
1011
+ "this repo and gives Claude browser control over those tabs."
858
1012
  ),
859
1013
  )
860
1014
  @click.option(
@@ -865,8 +1019,8 @@ def _launch_multi(
865
1019
  default=False,
866
1020
  help=(
867
1021
  "Interactive multi-pane setup: walk through a guided menu to "
868
- "configure window types, teams mode, and shared Chrome before "
869
- "launching tmux. Requires --multi."
1022
+ "configure windows, teams mode, and shared Chrome before launch. "
1023
+ "A single Claude pane falls back to a normal session."
870
1024
  ),
871
1025
  )
872
1026
  @click.argument("extra_args", nargs=-1, type=click.UNPROCESSED)
@@ -887,8 +1041,6 @@ def claude(
887
1041
  # Incompatibility checks
888
1042
  if do_team and do_multi:
889
1043
  raise click.ClickException("--team and --multi are incompatible (both manage tmux).")
890
- if do_interactive and not do_multi:
891
- raise click.ClickException("--interactive requires --multi.")
892
1044
  if do_interactive and do_team:
893
1045
  raise click.ClickException(
894
1046
  "--interactive and --team are incompatible (interactive handles teams mode itself)."
@@ -899,16 +1051,18 @@ def claude(
899
1051
  "(interactive handles Chrome setup itself)."
900
1052
  )
901
1053
 
1054
+ if do_interactive:
1055
+ _launch_interactive(
1056
+ ctx,
1057
+ safe=safe,
1058
+ use_aws=use_aws,
1059
+ cli_profile=cli_profile,
1060
+ extra_args=extra_args,
1061
+ worktree_name=worktree_name,
1062
+ )
1063
+ return
1064
+
902
1065
  if do_multi:
903
- if do_interactive:
904
- _launch_interactive_multi(
905
- ctx,
906
- safe=safe,
907
- use_aws=use_aws,
908
- cli_profile=cli_profile,
909
- extra_args=extra_args,
910
- )
911
- return
912
1066
  _launch_multi(
913
1067
  ctx,
914
1068
  safe=safe,
@@ -929,88 +1083,19 @@ def claude(
929
1083
  )
930
1084
  return
931
1085
 
932
- # Auto-init if .claude/ is missing
933
- # Load config first to check provider setting
1086
+ # Load config for the current project and launch a normal Claude session.
934
1087
  project = ctx.obj.get("project") if ctx.obj else None
935
1088
  config = load_config(project_override=project, project_dir=Path.cwd())
936
- use_bedrock = use_aws or config.claude_provider == "aws"
937
-
938
- manager, name, exec_env, config = _get_manager(
939
- ctx,
940
- bedrock=use_bedrock,
941
- bedrock_profile=cli_profile or "",
942
- )
943
-
944
- if use_bedrock:
945
- profile_label = exec_env.get("AWS_PROFILE", "default")
946
- region_label = exec_env.get("AWS_REGION", "us-east-1")
947
- bedrock_label = f" via Bedrock (profile={profile_label}, region={region_label})"
948
- console.print(
949
- f"Checking Bedrock access (profile={profile_label}, region={region_label})..."
950
- )
951
- _check_bedrock_access(name, exec_env)
952
- else:
953
- bedrock_label = ""
954
-
955
- # Resolve worktree working directory (if --worktree/-w was given)
956
- worktree_dir: str | None = None
957
- if worktree_name is not None:
958
- if worktree_name == "":
959
- worktree_name = _generate_worktree_name()
960
- container_project_dir = f"/root/projects/{config.project_name}"
961
- worktree_dir = _setup_worktree(name, container_project_dir, worktree_name)
962
- console.print(f"[dim]Worktree: {worktree_dir} (branch: worktree-{worktree_name})[/dim]")
963
-
964
- # Local Chrome bridge: probe debug port, write MCP config, inject into args
965
1089
  local_chrome = local_chrome or config.local_chrome is True
966
- mcp_args: list[str] = []
967
- if local_chrome:
968
- from ai_shell.local_chrome import (
969
- LocalChromeUnavailable,
970
- ensure_host_chrome,
971
- start_chrome_proxy,
972
- write_mcp_config,
973
- )
974
-
975
- console.print("[dim]Connecting to host Chrome...[/dim]")
976
- try:
977
- chrome_port = ensure_host_chrome(name)
978
- except LocalChromeUnavailable as exc:
979
- raise click.ClickException(str(exc)) from exc
980
-
981
- console.print(f"[dim]Chrome debug port {chrome_port} reachable.[/dim]")
982
-
983
- # Start TCP proxy: localhost:<port> -> host.docker.internal:<port>
984
- # Chrome rejects non-localhost Host headers, so the MCP server
985
- # must connect via localhost.
986
- start_chrome_proxy(name, chrome_port)
987
-
988
- mcp_path = write_mcp_config(chrome_port)
989
- container_mcp_path = "/etc/ai-shell/chrome-mcp.json"
990
- # Inject the MCP config file into the running container
991
- _inject_mcp_config(name, str(mcp_path), container_mcp_path)
992
- mcp_args = ["--mcp-config", container_mcp_path]
993
- console.print("[dim]Chrome DevTools MCP attached.[/dim]")
994
-
995
- if safe:
996
- cmd = ["claude", *mcp_args, *extra_args]
997
- console.print(f"[bold]Launching Claude Code (safe mode){bedrock_label} in {name}...[/bold]")
998
- manager.exec_interactive(name, cmd, extra_env=exec_env, workdir=worktree_dir)
999
- else:
1000
- # Try with -c first (continue previous conversation)
1001
- cmd_continue = ["claude", "--dangerously-skip-permissions", "-c", *mcp_args, *extra_args]
1002
- console.print(f"[bold]Launching Claude Code{bedrock_label} in {name}...[/bold]")
1003
- exit_code, elapsed = manager.run_interactive(
1004
- name, cmd_continue, extra_env=exec_env, workdir=worktree_dir
1005
- )
1006
-
1007
- if exit_code != 0 and elapsed < FAST_FAILURE_THRESHOLD:
1008
- # -c failed quickly (likely no prior conversation), retry without it
1009
- console.print("[yellow]No prior conversation found, starting fresh...[/yellow]")
1010
- cmd_fresh = ["claude", "--dangerously-skip-permissions", *mcp_args, *extra_args]
1011
- manager.exec_interactive(name, cmd_fresh, extra_env=exec_env, workdir=worktree_dir)
1012
- else:
1013
- sys.exit(exit_code)
1090
+ _launch_loaded_config_claude(
1091
+ config,
1092
+ safe=safe,
1093
+ use_aws=use_aws,
1094
+ cli_profile=cli_profile,
1095
+ extra_args=extra_args,
1096
+ local_chrome=local_chrome,
1097
+ worktree_name=worktree_name,
1098
+ )
1014
1099
 
1015
1100
 
1016
1101
  @click.command(context_settings=CONTEXT_SETTINGS)
@@ -65,7 +65,7 @@ class AiShellConfig:
65
65
  bedrock_profile: str = "" # AWS profile for Bedrock LLM API calls
66
66
 
67
67
  # Claude options
68
- local_chrome: bool = False # Attach Chrome DevTools MCP to host Chrome debug port
68
+ local_chrome: bool = False # Attach Chrome DevTools MCP to project-scoped host Chrome
69
69
 
70
70
  # Per-tool provider
71
71
  claude_provider: str = "" # "anthropic" (default) or "aws"
@@ -1,8 +1,8 @@
1
- """Interactive multi-pane wizard for ``ai-shell claude --multi -i``.
1
+ """Interactive launcher wizard for ``ai-shell claude -i``.
2
2
 
3
- Walks the user through a guided menu to configure each tmux pane,
4
- then converts the collected choices into :class:`~ai_shell.tmux.PaneSpec`
5
- objects ready for :func:`~ai_shell.tmux.build_tmux_commands`.
3
+ Walks the user through a guided menu to configure each pane, then converts the
4
+ collected choices into :class:`~ai_shell.tmux.PaneSpec` objects when a tmux
5
+ session is needed.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -48,6 +48,19 @@ class InteractiveConfig:
48
48
  team_mode: bool = False
49
49
  shared_chrome: bool = False
50
50
 
51
+ @property
52
+ def pane_count(self) -> int:
53
+ """Return the number of panes requested by the user."""
54
+ return len(self.pane_choices)
55
+
56
+ @property
57
+ def has_claude_panes(self) -> bool:
58
+ """Return ``True`` when at least one pane launches Claude."""
59
+ return any(
60
+ choice.pane_type in (PaneType.THIS_PROJECT, PaneType.WORKSPACE_REPO)
61
+ for choice in self.pane_choices
62
+ )
63
+
51
64
 
52
65
  # ── Option builder ───────────────────────────────────────────────────
53
66
 
@@ -69,7 +82,7 @@ def _build_pane_options(
69
82
  """Build the numbered option list for per-window type selection."""
70
83
  options: list[_PaneOption] = [
71
84
  _PaneOption(
72
- label=f"This project ({project_name}) - Claude in worktree",
85
+ label=f"This project ({project_name}) - Claude session",
73
86
  pane_type=PaneType.THIS_PROJECT,
74
87
  ),
75
88
  _PaneOption(
@@ -102,8 +115,12 @@ def run_interactive_wizard(
102
115
  project_name: str,
103
116
  workspace_repos: list[dict[str, Any]] | None = None,
104
117
  console: Console | None = None,
118
+ min_windows: int = 1,
119
+ max_windows: int = 4,
120
+ default_windows: int | None = None,
121
+ default_shared_chrome: bool = False,
105
122
  ) -> InteractiveConfig | None:
106
- """Walk the user through the interactive multi-pane setup.
123
+ """Walk the user through the interactive launcher flow.
107
124
 
108
125
  Returns :class:`InteractiveConfig` with all choices, or ``None`` if the
109
126
  user cancels (Ctrl-C / EOFError).
@@ -112,13 +129,15 @@ def run_interactive_wizard(
112
129
  console = Console(stderr=True)
113
130
 
114
131
  options = _build_pane_options(project_name, workspace_repos)
132
+ if default_windows is None:
133
+ default_windows = min_windows
115
134
 
116
135
  try:
117
136
  # Step 1: number of windows
118
137
  num_windows: int = click.prompt(
119
138
  "How many windows?",
120
- type=click.IntRange(2, 4),
121
- default=2,
139
+ type=click.IntRange(min_windows, max_windows),
140
+ default=default_windows,
122
141
  )
123
142
 
124
143
  # Step 2: per-window type
@@ -144,15 +163,23 @@ def run_interactive_wizard(
144
163
  )
145
164
 
146
165
  # Step 3: pre-launch options
147
- console.print()
148
- team_mode = click.confirm(
149
- "Enable teams mode on the primary Claude pane?",
150
- default=False,
151
- )
152
- shared_chrome = click.confirm(
153
- "Enable shared Chrome browser for all Claude panes?",
154
- default=False,
166
+ team_mode = False
167
+ shared_chrome = False
168
+ has_claude_panes = any(
169
+ choice.pane_type in (PaneType.THIS_PROJECT, PaneType.WORKSPACE_REPO)
170
+ for choice in choices
155
171
  )
172
+ if has_claude_panes:
173
+ console.print()
174
+ if num_windows == 1:
175
+ team_prompt = "Enable teams mode for this Claude pane?"
176
+ chrome_prompt = "Enable Chrome browser for this Claude pane?"
177
+ else:
178
+ team_prompt = "Enable teams mode on the primary Claude pane?"
179
+ chrome_prompt = "Enable shared Chrome browser for all Claude panes?"
180
+
181
+ team_mode = click.confirm(team_prompt, default=False)
182
+ shared_chrome = click.confirm(chrome_prompt, default=default_shared_chrome)
156
183
 
157
184
  except (EOFError, KeyboardInterrupt, click.Abort):
158
185
  return None
@@ -18,21 +18,29 @@ import json
18
18
  import logging
19
19
  import os
20
20
  import platform
21
- import socket
22
21
  import subprocess
23
22
  import time
23
+ from collections.abc import Callable
24
+ from hashlib import sha1
25
+ from http.client import HTTPConnection, HTTPException
24
26
  from pathlib import Path
25
27
 
28
+ from ai_shell.defaults import unique_project_name
29
+
26
30
  logger = logging.getLogger(__name__)
27
31
 
28
32
  CHROME_DEBUG_HOST = "host.docker.internal"
29
- DEFAULT_CHROME_DEBUG_PORT = 9222
33
+ CHROME_HOST_PROBE_TIMEOUT_SECONDS = 20.0
34
+ CHROME_CONTAINER_PROBE_TIMEOUT_SECONDS = 10.0
35
+ CHROME_PROBE_INTERVAL_SECONDS = 0.5
36
+ CHROME_DEBUG_PORT_RANGE_START = 40000
37
+ CHROME_DEBUG_PORT_RANGE_SIZE = 20000
30
38
 
31
39
  MCP_CONFIG_FILENAME = "chrome-mcp.json"
32
40
 
33
- # User-data-dir for the ai-shell debug Chrome profile (keeps it separate from
34
- # the user's normal browsing).
35
- _CHROME_PROFILE_DIR_NAME = "ai-debug-profile"
41
+ # User-data-dir for ai-shell project-specific Chrome profiles (keeps them
42
+ # separate from the user's normal browsing and from other repos).
43
+ _CHROME_PROFILE_ROOT_DIR_NAME = "ai-shell"
36
44
 
37
45
  # Well-known Chrome install paths on Windows
38
46
  _CHROME_CANDIDATES = [
@@ -50,20 +58,6 @@ _NODE_PROXY_TEMPLATE = (
50
58
  "}}).listen({port},'127.0.0.1')"
51
59
  )
52
60
 
53
- SETUP_INSTRUCTIONS = """\
54
- Chrome could not be found or launched automatically, and the debug port \
55
- is not reachable.
56
-
57
- To fix, launch Chrome manually with these flags:
58
-
59
- chrome.exe --remote-debugging-port=9222 \\
60
- --remote-debugging-address=127.0.0.1 \\
61
- --remote-allow-origins=* \\
62
- --user-data-dir="%LOCALAPPDATA%\\Google\\Chrome\\ai-debug-profile"
63
-
64
- Then re-run this command.
65
- See README.md "Attaching to your Windows Chrome" for details."""
66
-
67
61
 
68
62
  class LocalChromeUnavailable(Exception):
69
63
  """Raised when the host Chrome debug port is not reachable."""
@@ -89,23 +83,57 @@ def find_chrome() -> str | None:
89
83
  return None
90
84
 
91
85
 
92
- def _chrome_profile_dir() -> str:
93
- """Return the user-data-dir path for the ai-shell debug Chrome profile."""
86
+ def _project_slug(project_name: str, project_dir: str | Path | None = None) -> str:
87
+ """Return a stable slug for project-scoped Chrome state."""
88
+ if project_dir is not None:
89
+ try:
90
+ return unique_project_name(Path(project_dir), project_name)
91
+ except (TypeError, ValueError):
92
+ logger.debug("Falling back to project-name-only slug for %s", project_name)
93
+ slug = "".join(ch if ch.isalnum() or ch == "-" else "-" for ch in project_name.lower())
94
+ slug = "-".join(part for part in slug.split("-") if part)
95
+ return slug or "project"
96
+
97
+
98
+ def _chrome_profile_dir(project_name: str, project_dir: str | Path | None = None) -> str:
99
+ """Return the user-data-dir path for a project's ai-shell debug Chrome."""
100
+ slug = _project_slug(project_name, project_dir)
94
101
  local_app = os.environ.get("LOCALAPPDATA", "")
95
102
  if local_app:
96
- return str(Path(local_app) / "Google" / "Chrome" / _CHROME_PROFILE_DIR_NAME)
97
- return str(Path.home() / ".config" / "google-chrome" / _CHROME_PROFILE_DIR_NAME)
103
+ return str(Path(local_app) / "Google" / "Chrome" / _CHROME_PROFILE_ROOT_DIR_NAME / slug)
104
+ return str(Path.home() / ".config" / "google-chrome" / _CHROME_PROFILE_ROOT_DIR_NAME / slug)
98
105
 
99
106
 
100
- def _find_free_port() -> int:
101
- """Find a free TCP port on the host by briefly binding to port 0."""
102
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
103
- s.bind(("127.0.0.1", 0))
104
- port: int = s.getsockname()[1]
105
- return port
107
+ def _project_debug_port(project_name: str, project_dir: str | Path | None = None) -> int:
108
+ """Return a stable per-project Chrome remote debugging port."""
109
+ slug = _project_slug(project_name, project_dir)
110
+ digest = sha1(slug.encode("utf-8"), usedforsecurity=False).hexdigest()
111
+ return CHROME_DEBUG_PORT_RANGE_START + (int(digest[:8], 16) % CHROME_DEBUG_PORT_RANGE_SIZE)
112
+
113
+
114
+ def _build_setup_instructions(project_name: str, profile_dir: str, port: int) -> str:
115
+ """Return manual setup instructions for the project's Chrome profile."""
116
+ return f"""\
117
+ Chrome could not be found or launched automatically, and the debug port \
118
+ is not reachable.
119
+
120
+ To fix, launch Chrome manually with these flags:
121
+
122
+ chrome.exe --remote-debugging-port={port} \\
123
+ --remote-debugging-address=127.0.0.1 \\
124
+ --remote-allow-origins=* \\
125
+ --user-data-dir="{profile_dir}"
126
+
127
+ Then re-run this command for project '{project_name}'.
128
+ See README.md "Attaching to your Windows Chrome" for details."""
106
129
 
107
130
 
108
- def launch_chrome(port: int) -> bool:
131
+ def launch_chrome(
132
+ port: int,
133
+ *,
134
+ project_name: str,
135
+ project_dir: str | Path | None = None,
136
+ ) -> bool:
109
137
  """Launch Chrome on the host with the debug port enabled.
110
138
 
111
139
  Returns ``True`` if Chrome was launched, ``False`` if Chrome could
@@ -115,7 +143,7 @@ def launch_chrome(port: int) -> bool:
115
143
  if chrome_path is None:
116
144
  return False
117
145
 
118
- profile_dir = _chrome_profile_dir()
146
+ profile_dir = _chrome_profile_dir(project_name, project_dir)
119
147
  args = [
120
148
  chrome_path,
121
149
  f"--remote-debugging-port={port}",
@@ -167,38 +195,86 @@ def probe_chrome_port(container_name: str, port: int) -> bool:
167
195
  return True
168
196
 
169
197
 
170
- def ensure_host_chrome(container_name: str) -> int:
198
+ def probe_host_chrome_port(port: int) -> bool:
199
+ """Check whether a Chrome debug port is reachable on the host."""
200
+ connection = HTTPConnection("127.0.0.1", port, timeout=2)
201
+ try:
202
+ connection.request("GET", "/json/version")
203
+ response = connection.getresponse()
204
+ return response.status == 200 and bool(response.read().strip())
205
+ except (OSError, HTTPException):
206
+ return False
207
+ finally:
208
+ connection.close()
209
+
210
+
211
+ def _wait_until_ready(
212
+ probe_fn: Callable[..., bool],
213
+ *args: object,
214
+ timeout_seconds: float,
215
+ interval_seconds: float = CHROME_PROBE_INTERVAL_SECONDS,
216
+ ) -> bool:
217
+ """Poll until a probe succeeds or the timeout expires."""
218
+ deadline = time.monotonic() + timeout_seconds
219
+ while True:
220
+ if probe_fn(*args):
221
+ return True
222
+ remaining = deadline - time.monotonic()
223
+ if remaining <= 0:
224
+ return False
225
+ time.sleep(min(interval_seconds, remaining))
226
+
227
+
228
+ def ensure_host_chrome(
229
+ container_name: str,
230
+ *,
231
+ project_name: str,
232
+ project_dir: str | Path | None = None,
233
+ ) -> int:
171
234
  """Ensure Chrome is running with a debug port reachable from the container.
172
235
 
173
- 1. Probe the default port (9222) -- if Chrome is already running, return it.
174
- 2. Find a free port, launch Chrome on it, wait briefly for startup.
175
- 3. Raise :class:`LocalChromeUnavailable` if Chrome can't be found or started.
236
+ Each project gets its own debug profile directory and a stable debug port,
237
+ so different repos can keep separate logged-in Chrome instances alive.
176
238
 
177
239
  Returns the port number Chrome is listening on.
178
240
  """
179
- # Try the default port first -- user may have Chrome open already
180
- if probe_chrome_port(container_name, DEFAULT_CHROME_DEBUG_PORT):
181
- return DEFAULT_CHROME_DEBUG_PORT
182
-
183
- # Launch Chrome on a fresh port
184
- port = _find_free_port()
185
- logger.info(
186
- "Chrome not found on port %d, launching on port %d", DEFAULT_CHROME_DEBUG_PORT, port
187
- )
241
+ port = _project_debug_port(project_name, project_dir)
242
+ profile_dir = _chrome_profile_dir(project_name, project_dir)
243
+
244
+ if probe_chrome_port(container_name, port):
245
+ return port
188
246
 
189
- if not launch_chrome(port):
190
- raise LocalChromeUnavailable(SETUP_INSTRUCTIONS)
247
+ logger.info("Chrome for project %s not found on port %d, launching it", project_name, port)
248
+
249
+ if not launch_chrome(port, project_name=project_name, project_dir=project_dir):
250
+ raise LocalChromeUnavailable(_build_setup_instructions(project_name, profile_dir, port))
251
+
252
+ if not _wait_until_ready(
253
+ probe_host_chrome_port,
254
+ port,
255
+ timeout_seconds=CHROME_HOST_PROBE_TIMEOUT_SECONDS,
256
+ ):
257
+ raise LocalChromeUnavailable(
258
+ f"Chrome was launched for project '{project_name}' on port {port}, but the "
259
+ f"debug port did not open on localhost within "
260
+ f"{int(CHROME_HOST_PROBE_TIMEOUT_SECONDS)} seconds.\n\n"
261
+ "If another ai-shell Chrome window for this project is already open, "
262
+ "close it and retry."
263
+ )
191
264
 
192
- # Brief wait for Chrome to start (typically <2s)
193
- for attempt in range(5):
194
- time.sleep(1)
195
- if probe_chrome_port(container_name, port):
196
- logger.info("Chrome ready on port %d after %ds", port, attempt + 1)
197
- return port
265
+ if _wait_until_ready(
266
+ probe_chrome_port,
267
+ container_name,
268
+ port,
269
+ timeout_seconds=CHROME_CONTAINER_PROBE_TIMEOUT_SECONDS,
270
+ ):
271
+ logger.info("Chrome ready on port %d for project %s", port, project_name)
272
+ return port
198
273
 
199
274
  raise LocalChromeUnavailable(
200
- f"Chrome was launched on port {port} but the debug port did not become "
201
- "reachable within 5 seconds.\n\n"
275
+ f"Chrome is listening on localhost:{port} for project '{project_name}', but the "
276
+ "debug port did not become reachable from the dev container within "
277
+ f"{int(CHROME_CONTAINER_PROBE_TIMEOUT_SECONDS)} seconds.\n\n"
202
278
  "Check that Docker Desktop can reach the host via host.docker.internal."
203
279
  )
204
280
 
@@ -250,7 +250,7 @@ def build_tmux_commands(
250
250
  # Status bar
251
251
  ("status-style", "bg=colour235 fg=colour248"),
252
252
  ("status-left", "#[fg=colour172,bold] #S #[fg=colour248]| "),
253
- ("status-right", "#[fg=colour95] C-b z=zoom C-b d=detach "),
253
+ ("status-right", "#[fg=colour95] C-b: ◫o +c ▦␣ ◀p ▶n ⛶z d ⌦& "),
254
254
  ("status-left-length", "40"),
255
255
  ("status-right-length", "40"),
256
256
  ]