kiwi-code 0.0.445__tar.gz → 0.0.447__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 (69) hide show
  1. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/PKG-INFO +2 -3
  2. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/pyproject.toml +2 -3
  3. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/__init__.py +2 -1
  4. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_runtime/__init__.py +2 -1
  5. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_runtime/main.py +305 -25
  6. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/__init__.py +1 -1
  7. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_tui_interactive_runtime.py +24 -8
  8. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/uv.lock +919 -910
  9. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/.github/workflows/publish.yml +0 -0
  10. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/.github/workflows/test.yml +0 -0
  11. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/.gitignore +0 -0
  12. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/.python-version +0 -0
  13. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/CLAUDE.md +0 -0
  14. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/Makefile +0 -0
  15. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/README.md +0 -0
  16. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/auth.py +0 -0
  17. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/checkpoints.py +0 -0
  18. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/cli.py +0 -0
  19. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/client.py +0 -0
  20. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/commands.py +0 -0
  21. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/logger.py +0 -0
  22. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/models.py +0 -0
  23. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/runtime_manager.py +0 -0
  24. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/server.py +0 -0
  25. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/terminal_mode.py +0 -0
  26. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_runtime/__main__.py +0 -0
  27. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/inline_file_picker.py +0 -0
  28. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/main.py +0 -0
  29. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/random_words.py +0 -0
  30. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/runtime_agent.py +0 -0
  31. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/__init__.py +0 -0
  32. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/attach_content.py +0 -0
  33. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/command_result.py +0 -0
  34. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/dashboard.py +0 -0
  35. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/detach_files.py +0 -0
  36. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/file_browser.py +0 -0
  37. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/help.py +0 -0
  38. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/id_picker.py +0 -0
  39. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/login.py +0 -0
  40. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  41. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  42. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/slash_picker.py +0 -0
  43. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/term_dashboard.py +0 -0
  44. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/slash_commands.py +0 -0
  45. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/status_words.py +0 -0
  46. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/term_app.py +0 -0
  47. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/ui_settings.py +0 -0
  48. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/widgets.py +0 -0
  49. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/worktrees.py +0 -0
  50. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/test_hello.py +0 -0
  51. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/__init__.py +0 -0
  52. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/conftest.py +0 -0
  53. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_batch_fs_tool.py +0 -0
  54. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_checkpoints.py +0 -0
  55. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_cli_help.py +0 -0
  56. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_copy_path_fs_tool.py +0 -0
  57. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_imports.py +0 -0
  58. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_read_file_streaming.py +0 -0
  59. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_reexec_kiwi.py +0 -0
  60. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_runtime_log_trimming.py +0 -0
  61. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_search_in_files_fs_tool.py +0 -0
  62. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_slash_commands.py +0 -0
  63. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_term_dashboard_ui.py +0 -0
  64. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_terminal_mode.py +0 -0
  65. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_tokens.py +0 -0
  66. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_tui_headless.py +0 -0
  67. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_tui_palette.py +0 -0
  68. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_unified_diff.py +0 -0
  69. {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_worktrees.py +0 -0
@@ -1,10 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.445
3
+ Version: 0.0.447
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
- Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
7
- Author-email: Anurag Jha <anurag@meetkiwi.co>
6
+ Author-email: Anurag <anurag@meetkiwi.co>
8
7
  License: Proprietary
9
8
  Keywords: cli,terminal,textual,tui
10
9
  Classifier: Development Status :: 3 - Alpha
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.445"
3
+ version = "0.0.447"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -19,7 +19,7 @@ dependencies = [
19
19
  ]
20
20
 
21
21
  authors = [
22
- { name = "Anurag Jha", email = "anurag@meetkiwi.co" }
22
+ { name = "Anurag", email = "anurag@meetkiwi.co" }
23
23
  ]
24
24
  license = { text = "Proprietary" }
25
25
  keywords = ["tui", "textual", "terminal", "cli"]
@@ -35,7 +35,6 @@ classifiers = [
35
35
 
36
36
  [project.urls]
37
37
  Homepage = "https://meetkiwi.ai"
38
- Repository = "https://github.com/jetoslabs/kiwi-code"
39
38
 
40
39
  [project.scripts]
41
40
  kiwi = "kiwi_tui.main:main"
@@ -1,3 +1,4 @@
1
1
  """Kiwi CLI - command-line interface and shared infrastructure modules."""
2
2
 
3
- __version__ = "0.0.445"
3
+
4
+ __version__ = "0.0.447"
@@ -1,3 +1,4 @@
1
1
  """Kiwi Runtime — terminal agent that connects to the server via WebSocket."""
2
2
 
3
- __version__ = "0.0.445"
3
+
4
+ __version__ = "0.0.447"
@@ -3075,6 +3075,98 @@ def validate_command(
3075
3075
  return True, ""
3076
3076
 
3077
3077
 
3078
+ def _looks_interactive_oneoff(command: str) -> str | None:
3079
+ """Best-effort detection of commands that are likely to require a TTY / user input.
3080
+
3081
+ This is intentionally conservative and only catches common cases that have caused
3082
+ terminals to appear "hung" when mistakenly executed as one-off commands.
3083
+ """
3084
+ try:
3085
+ tokens = shlex.split(command)
3086
+ except Exception:
3087
+ tokens = (command or "").strip().split()
3088
+ if not tokens:
3089
+ return None
3090
+ exe = os.path.basename(tokens[0]).lower()
3091
+ args = [t.lower() for t in tokens[1:]]
3092
+ # Strip flags for subcommand detection (supports patterns like: gcloud --project X auth login).
3093
+ nonflags = [a for a in args if not a.startswith("-")]
3094
+
3095
+ # Google Cloud / BigQuery CLIs: commonly interactive for auth/init flows.
3096
+ if exe == "gcloud":
3097
+ quiet = ("--quiet" in args) or ("-q" in args)
3098
+ # gcloud init is explicitly interactive.
3099
+ if "init" in nonflags:
3100
+ return "gcloud init"
3101
+
3102
+ # Detect interactive auth flows even when preceded by groups like `alpha`/`beta`.
3103
+ for i, tok in enumerate(nonflags):
3104
+ # gcloud auth login (opens browser / requires user interaction).
3105
+ if tok == "auth" and i + 1 < len(nonflags) and nonflags[i + 1] == "login":
3106
+ return "gcloud auth login"
3107
+ # gcloud auth application-default login
3108
+ if (
3109
+ tok == "auth"
3110
+ and i + 2 < len(nonflags)
3111
+ and nonflags[i + 1] == "application-default"
3112
+ and nonflags[i + 2] == "login"
3113
+ ):
3114
+ return "gcloud auth application-default login"
3115
+
3116
+ # component management often prompts unless --quiet/-q is set.
3117
+ if (
3118
+ (not quiet)
3119
+ and tok == "components"
3120
+ and i + 1 < len(nonflags)
3121
+ and nonflags[i + 1] in {"install", "update", "remove", "reinstall"}
3122
+ ):
3123
+ return f"gcloud components {nonflags[i + 1]}"
3124
+
3125
+ if exe == "bq":
3126
+ # bq init configures defaults and is interactive; other bq commands are generally non-interactive.
3127
+ if "init" in nonflags:
3128
+ return "bq init"
3129
+
3130
+ # npm / node ecosystem: certain subcommands prompt for credentials or project metadata.
3131
+ if exe in {"npm", "pnpm", "yarn", "bun"}:
3132
+ sub = nonflags[0] if nonflags else ""
3133
+ # `npm init` is interactive unless -y/--yes is provided.
3134
+ if sub == "init" and ("-y" not in args and "--yes" not in args):
3135
+ return f"{exe} init"
3136
+ if sub in {"login", "adduser", "publish", "create"}:
3137
+ return f"{exe} {sub}"
3138
+ # Common interactive shells / editors / pagers / REPLs
3139
+ hard_interactive = {
3140
+ "ssh", "sudo", "su", "vim", "vi", "nano", "less", "more", "top", "htop", "passwd",
3141
+ "mysql", "psql", "redis-cli", "telnet", "sftp", "ftp",
3142
+ }
3143
+ if exe in hard_interactive:
3144
+ return exe
3145
+
3146
+ # REPLs when invoked without an explicit script / command
3147
+ if exe in {"python", "python3", "node"} and len(tokens) == 1:
3148
+ return exe
3149
+
3150
+ # `cat` with no args waits on stdin
3151
+ if exe == "cat" and len(tokens) == 1:
3152
+ return exe
3153
+
3154
+ # `watch` is long-running
3155
+ if exe == "watch":
3156
+ return exe
3157
+
3158
+ # `ping` without count is long-running on macOS/Linux
3159
+ if exe == "ping" and ("-c" not in args and "--count" not in args):
3160
+ return exe
3161
+
3162
+ # `tail -f` follows forever
3163
+ if exe == "tail" and ("-f" in args or "--follow" in args):
3164
+ return "tail -f"
3165
+
3166
+ return None
3167
+
3168
+
3169
+
3078
3170
  _SAFE_PATHS = frozenset({
3079
3171
  "/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr",
3080
3172
  "/dev/zero", "/dev/random", "/dev/urandom", "/dev/tty",
@@ -3241,7 +3333,7 @@ async def login(http_base_url: str, email: str, password: str) -> str:
3241
3333
 
3242
3334
  async def run_command(
3243
3335
  command: str,
3244
- timeout: int = 120,
3336
+ timeout: int = 30,
3245
3337
  mode: str = "restricted",
3246
3338
  allowed_dirs: list[str] | None = None,
3247
3339
  ) -> dict:
@@ -3255,12 +3347,20 @@ async def run_command(
3255
3347
  proc: asyncio.subprocess.Process | None = None
3256
3348
  try:
3257
3349
  cwd = allowed_dirs[0] if allowed_dirs else None
3258
- proc = await asyncio.create_subprocess_shell(
3259
- command,
3260
- stdout=asyncio.subprocess.PIPE,
3261
- stderr=asyncio.subprocess.PIPE,
3262
- cwd=cwd,
3263
- )
3350
+
3351
+ # One-off commands must be non-interactive: never read from the user's TTY.
3352
+ # On macOS/Linux this prevents accidental terminal hijacking (e.g. ssh/sudo/vim).
3353
+ # We also start a new session so we can safely kill the whole process group.
3354
+ popen_kwargs: dict[str, Any] = {
3355
+ "stdout": asyncio.subprocess.PIPE,
3356
+ "stderr": asyncio.subprocess.PIPE,
3357
+ "stdin": asyncio.subprocess.DEVNULL,
3358
+ "cwd": cwd,
3359
+ }
3360
+ if not IS_WINDOWS:
3361
+ popen_kwargs["start_new_session"] = True
3362
+
3363
+ proc = await asyncio.create_subprocess_shell(command, **popen_kwargs)
3264
3364
  stdout_bytes, stderr_bytes = await asyncio.wait_for(
3265
3365
  proc.communicate(), timeout=timeout
3266
3366
  )
@@ -3269,8 +3369,22 @@ async def run_command(
3269
3369
  return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode}
3270
3370
  except asyncio.TimeoutError:
3271
3371
  if proc and proc.returncode is None:
3372
+ # On POSIX, prefer killing the whole process group so child processes
3373
+ # (ssh/vim/etc.) can't survive and keep the user's terminal wedged.
3272
3374
  try:
3273
- proc.kill()
3375
+ if not IS_WINDOWS:
3376
+ try:
3377
+ os.killpg(proc.pid, signal.SIGKILL)
3378
+ except ProcessLookupError:
3379
+ pass
3380
+ except Exception:
3381
+ # Fallback: kill just the parent.
3382
+ try:
3383
+ proc.kill()
3384
+ except Exception:
3385
+ pass
3386
+ else:
3387
+ proc.kill()
3274
3388
  except ProcessLookupError:
3275
3389
  pass
3276
3390
  try:
@@ -3280,8 +3394,22 @@ async def run_command(
3280
3394
  return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
3281
3395
  except asyncio.CancelledError:
3282
3396
  if proc and proc.returncode is None:
3397
+ # On POSIX, prefer killing the whole process group so child processes
3398
+ # (ssh/vim/etc.) can't survive and keep the user's terminal wedged.
3283
3399
  try:
3284
- proc.kill()
3400
+ if not IS_WINDOWS:
3401
+ try:
3402
+ os.killpg(proc.pid, signal.SIGKILL)
3403
+ except ProcessLookupError:
3404
+ pass
3405
+ except Exception:
3406
+ # Fallback: kill just the parent.
3407
+ try:
3408
+ proc.kill()
3409
+ except Exception:
3410
+ pass
3411
+ else:
3412
+ proc.kill()
3285
3413
  except ProcessLookupError:
3286
3414
  pass
3287
3415
  try:
@@ -3300,16 +3428,29 @@ async def run_command(
3300
3428
 
3301
3429
  async def run_interactive_command(
3302
3430
  command: str,
3431
+ *,
3432
+ timeout_sec: int | None = None,
3303
3433
  mode: str = "restricted",
3304
3434
  allowed_dirs: list[str] | None = None,
3305
3435
  request_id: str | None = None,
3306
- ) -> dict:
3307
- """Execute a command with the user's terminal attached for interactive I/O."""
3436
+ ) -> dict:
3437
+ """Execute a command with the user's terminal attached for interactive I/O.
3438
+
3439
+ Note: interactive commands can block indefinitely waiting on user input. We therefore
3440
+ support an optional timeout_sec; on timeout we terminate the session so the runtime
3441
+ can continue processing subsequent commands.
3442
+ """
3308
3443
  if mode == "restricted" and allowed_dirs:
3309
3444
  ok, reason = validate_command(command, allowed_dirs)
3310
3445
  if not ok:
3311
3446
  return {"stdout": "", "stderr": reason, "exit_code": 1}
3312
3447
 
3448
+ timeout_eff: int | None
3449
+ try:
3450
+ timeout_eff = int(timeout_sec) if timeout_sec else None
3451
+ except Exception:
3452
+ timeout_eff = None
3453
+
3313
3454
  control_dir = _tui_managed_control_dir()
3314
3455
  if control_dir is not None:
3315
3456
  return await run_tui_managed_interactive_command(
@@ -3318,6 +3459,7 @@ async def run_interactive_command(
3318
3459
  control_dir=control_dir,
3319
3460
  mode=mode,
3320
3461
  allowed_dirs=allowed_dirs,
3462
+ timeout_sec=timeout_eff,
3321
3463
  )
3322
3464
 
3323
3465
  try:
@@ -3327,14 +3469,90 @@ async def run_interactive_command(
3327
3469
  print(f" {CB}{'─' * 50}{RESET}")
3328
3470
  print(f" {GREY}You are now in control. Complete the prompts below.{RESET}\n")
3329
3471
 
3330
- exit_code = await asyncio.get_event_loop().run_in_executor(
3331
- None,
3332
- lambda: subprocess.run(
3333
- command,
3334
- shell=True,
3335
- cwd=cwd,
3336
- ).returncode,
3337
- )
3472
+ def _run() -> int:
3473
+ """Run an interactive command in the user's terminal.
3474
+
3475
+ Important: some interactive CLIs (notably ones using full-screen / raw-mode UIs)
3476
+ can leave the TTY in a state where Ctrl+C no longer generates SIGINT if they are
3477
+ killed abruptly. We therefore snapshot and restore terminal attributes on exit.
3478
+ """
3479
+ fd = None
3480
+ old_term = None
3481
+ try:
3482
+ if not IS_WINDOWS:
3483
+ try:
3484
+ if sys.stdin is not None and sys.stdin.isatty():
3485
+ fd = sys.stdin.fileno()
3486
+ old_term = termios.tcgetattr(fd)
3487
+ except Exception:
3488
+ fd = None
3489
+ old_term = None
3490
+
3491
+ popen_kwargs: dict[str, Any] = {"shell": True, "cwd": cwd}
3492
+ if not IS_WINDOWS:
3493
+ popen_kwargs["start_new_session"] = True
3494
+ proc = subprocess.Popen(command, **popen_kwargs)
3495
+ try:
3496
+ proc.wait(timeout=timeout_eff if timeout_eff and timeout_eff > 0 else None)
3497
+ return int(proc.returncode or 0)
3498
+ except subprocess.TimeoutExpired:
3499
+ # Timeout: terminate the whole process group so the runtime can continue.
3500
+ if not IS_WINDOWS:
3501
+ try:
3502
+ os.killpg(proc.pid, signal.SIGINT)
3503
+ time.sleep(0.3)
3504
+ except Exception:
3505
+ pass
3506
+ try:
3507
+ os.killpg(proc.pid, signal.SIGTERM)
3508
+ time.sleep(0.3)
3509
+ except Exception:
3510
+ pass
3511
+ try:
3512
+ os.killpg(proc.pid, signal.SIGKILL)
3513
+ except Exception:
3514
+ pass
3515
+ else:
3516
+ try:
3517
+ proc.terminate()
3518
+ except Exception:
3519
+ pass
3520
+ time.sleep(0.3)
3521
+ try:
3522
+ proc.kill()
3523
+ except Exception:
3524
+ pass
3525
+ try:
3526
+ proc.wait(timeout=2)
3527
+ except Exception:
3528
+ pass
3529
+ return -1
3530
+ finally:
3531
+ if old_term is not None and fd is not None:
3532
+ try:
3533
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_term)
3534
+ except Exception:
3535
+ pass
3536
+
3537
+ # Restore cursor visibility + cursor style (some interactive CLIs switch to a
3538
+ # block cursor or hide it and don't restore on abrupt termination).
3539
+ # - Show cursor: DECTCEM
3540
+ # - Reset cursor style to a bar cursor (DECSCUSR)
3541
+ try:
3542
+ if fd is not None:
3543
+ os.write(fd, b"\x1b[?25h") # show cursor
3544
+ os.write(fd, b"\x1b[0 q") # restore terminal default cursor style
3545
+ except Exception:
3546
+ pass
3547
+
3548
+ exit_code = await asyncio.get_event_loop().run_in_executor(None, _run)
3549
+
3550
+ if exit_code == -1 and timeout_eff:
3551
+ return {
3552
+ "stdout": "",
3553
+ "stderr": f"Interactive command timed out after {timeout_eff}s and was terminated.",
3554
+ "exit_code": -1,
3555
+ }
3338
3556
 
3339
3557
  print(f"\n {CB}{'─' * 50}{RESET}")
3340
3558
  status = f"{GREEN}OK{RESET}" if exit_code == 0 else f"{RED}exit {exit_code}{RESET}"
@@ -4013,7 +4231,8 @@ async def run_tui_managed_interactive_command(
4013
4231
  control_dir: Path,
4014
4232
  mode: str = "restricted",
4015
4233
  allowed_dirs: list[str] | None = None,
4016
- ) -> dict:
4234
+ timeout_sec: int | None = None,
4235
+ ) -> dict:
4017
4236
  if mode == "restricted" and allowed_dirs:
4018
4237
  ok, reason = validate_command(command, allowed_dirs)
4019
4238
  if not ok:
@@ -4041,7 +4260,31 @@ async def run_tui_managed_interactive_command(
4041
4260
  pty_proc = PTYProcess(session_id, command, 120, 40)
4042
4261
  await pty_proc.start(bridge, cwd=cwd)
4043
4262
  input_task = asyncio.create_task(bridge.pump_inputs(pty_proc))
4044
- exit_code = await bridge.wait()
4263
+
4264
+ try:
4265
+ if timeout_sec and timeout_sec > 0:
4266
+ exit_code = await asyncio.wait_for(bridge.wait(), timeout=float(timeout_sec))
4267
+ else:
4268
+ exit_code = await bridge.wait()
4269
+ except asyncio.TimeoutError:
4270
+ # Timeout: terminate the PTY session so the runtime isn't blocked.
4271
+ msg = f"[Interactive timeout] Command timed out after {timeout_sec}s; terminating.\r\n"
4272
+ bridge.append_output(msg)
4273
+ try:
4274
+ bridge._write_status(
4275
+ active=False,
4276
+ ended_at=datetime.now().isoformat(),
4277
+ exit_code=-1,
4278
+ error=f"timeout_after_{timeout_sec}s",
4279
+ )
4280
+ except Exception:
4281
+ pass
4282
+ return {
4283
+ "stdout": "",
4284
+ "stderr": f"Interactive command timed out after {timeout_sec}s and was terminated.",
4285
+ "exit_code": -1,
4286
+ }
4287
+
4045
4288
  return {
4046
4289
  "stdout": "(interactive session completed)",
4047
4290
  "stderr": "",
@@ -4245,18 +4488,25 @@ async def connect(
4245
4488
  pending_lock = asyncio.Lock()
4246
4489
  pending_event = asyncio.Event()
4247
4490
 
4248
- command_queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(maxsize=64)
4491
+ command_queue: asyncio.Queue[tuple[str, str, int]] = asyncio.Queue(maxsize=64)
4249
4492
  cmd_state: dict[str, Any] = {"running": False, "command": None}
4250
4493
 
4251
4494
  async def _command_worker() -> None:
4252
4495
  while True:
4253
- request_id, command = await command_queue.get()
4496
+ request_id, command, timeout_sec = await command_queue.get()
4254
4497
  result_msg: dict[str, Any] | None = None
4255
4498
  cmd_state["running"] = True
4256
4499
  cmd_state["command"] = command
4257
4500
  try:
4501
+ # If no timeout is provided (or it's invalid), default to 30s.
4502
+ try:
4503
+ timeout_eff = int(timeout_sec) if timeout_sec else 30
4504
+ except Exception:
4505
+ timeout_eff = 30
4506
+
4258
4507
  result = await run_command(
4259
4508
  command,
4509
+ timeout=timeout_eff,
4260
4510
  mode=mode,
4261
4511
  allowed_dirs=allowed_dirs,
4262
4512
  )
@@ -4374,7 +4624,7 @@ async def _connect_once(
4374
4624
  relogin_email: str | None = None,
4375
4625
  relogin_password: str | None = None,
4376
4626
  *,
4377
- command_queue: asyncio.Queue[tuple[str, str]],
4627
+ command_queue: asyncio.Queue[tuple[str, str, int]],
4378
4628
  pending_results: deque[dict[str, Any]],
4379
4629
  pending_lock: asyncio.Lock,
4380
4630
  pending_event: asyncio.Event,
@@ -4550,8 +4800,30 @@ async def _connect_once(
4550
4800
  command = msg.get("command", "")
4551
4801
  print_cmd_log(request_id, f"$ {BOLD}{command}{RESET}")
4552
4802
 
4803
+ interactive_hint = _looks_interactive_oneoff(str(command))
4804
+ if interactive_hint:
4805
+ err = (
4806
+ f"This command looks interactive ({interactive_hint}) and can't be run as a one-off. "
4807
+ "Re-run with interactive=true, or start a session (session_id='new')."
4808
+ )
4809
+ print_cmd_log(request_id, f"{RED}FAILED{RESET}", False)
4810
+ await ws.send(json.dumps({
4811
+ "type": "result",
4812
+ "request_id": request_id,
4813
+ "stdout": "",
4814
+ "stderr": err,
4815
+ "exit_code": 1,
4816
+ }))
4817
+ continue
4818
+
4553
4819
  try:
4554
- command_queue.put_nowait((str(request_id), str(command)))
4820
+ # Allow the server to pass a per-command timeout.
4821
+ # If missing/invalid, default to 30s.
4822
+ try:
4823
+ timeout_sec = int(msg.get("timeout_sec", 30) or 30)
4824
+ except Exception:
4825
+ timeout_sec = 30
4826
+ command_queue.put_nowait((str(request_id), str(command), timeout_sec))
4555
4827
  except asyncio.QueueFull:
4556
4828
  err = "Runtime command queue full; please retry."
4557
4829
  print_cmd_log(request_id, f"{RED}FAILED{RESET}", False)
@@ -4569,8 +4841,16 @@ async def _connect_once(
4569
4841
  command = msg.get("command", "")
4570
4842
  print_cmd_log(request_id, f"$ {BOLD}{command}{RESET} {GREY}(interactive){RESET}")
4571
4843
 
4844
+ # Support per-command timeouts for interactive runs as well.
4845
+ # If missing/invalid, default to 120s (interactive flows can take time).
4846
+ try:
4847
+ timeout_sec = int(msg.get("timeout_sec", 120) or 120)
4848
+ except Exception:
4849
+ timeout_sec = 120
4850
+
4572
4851
  result = await run_interactive_command(
4573
4852
  command,
4853
+ timeout_sec=timeout_sec,
4574
4854
  mode=mode,
4575
4855
  allowed_dirs=allowed_dirs,
4576
4856
  request_id=request_id,
@@ -1,3 +1,3 @@
1
1
  """Autobots TUI - A textual-based terminal user interface."""
2
2
 
3
- __version__ = "0.0.445"
3
+ __version__ = "0.0.447"
@@ -267,14 +267,28 @@ def test_run_interactive_command_preserves_standalone_path(monkeypatch: pytest.M
267
267
 
268
268
  calls: list[dict[str, object]] = []
269
269
 
270
- class _Result:
271
- returncode = 0
270
+ class _FakePopen:
271
+ def __init__(self, pid: int = 12345):
272
+ self.pid = pid
273
+ self.returncode = 0
272
274
 
273
- def _fake_run(command: str, *, shell: bool, cwd: str | None = None):
274
- calls.append({"command": command, "shell": shell, "cwd": cwd})
275
- return _Result()
275
+ def wait(self, timeout: float | None = None):
276
+ # Immediately succeed (standalone path).
277
+ self.returncode = 0
278
+ return 0
276
279
 
277
- monkeypatch.setattr(runtime_main.subprocess, "run", _fake_run)
280
+ def terminate(self):
281
+ self.returncode = -15
282
+
283
+ def kill(self):
284
+ self.returncode = -9
285
+
286
+ def _fake_popen(command: str, **kwargs):
287
+ calls.append({"command": command, **kwargs})
288
+ return _FakePopen()
289
+
290
+ # Standalone interactive path currently uses subprocess.Popen (not subprocess.run).
291
+ monkeypatch.setattr(runtime_main.subprocess, "Popen", _fake_popen)
278
292
 
279
293
  result = asyncio.run(
280
294
  runtime_main.run_interactive_command(
@@ -290,8 +304,10 @@ def test_run_interactive_command_preserves_standalone_path(monkeypatch: pytest.M
290
304
  "stderr": "",
291
305
  "exit_code": 0,
292
306
  }
293
- assert calls == [{"command": "printf 'ok'", "shell": True, "cwd": "/tmp"}]
294
-
307
+ assert calls, "Expected subprocess.Popen to be called in standalone interactive mode"
308
+ assert calls[0]["command"] == "printf 'ok'"
309
+ assert calls[0].get("shell") is True
310
+ assert calls[0].get("cwd") == "/tmp"
295
311
 
296
312
  @pytest.mark.asyncio
297
313
  async def test_ctrl_o_opens_live_console_from_dashboard(