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.
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/PKG-INFO +2 -3
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/pyproject.toml +2 -3
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/__init__.py +2 -1
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_runtime/__init__.py +2 -1
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_runtime/main.py +305 -25
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/__init__.py +1 -1
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_tui_interactive_runtime.py +24 -8
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/uv.lock +919 -910
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/.gitignore +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/.python-version +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/CLAUDE.md +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/Makefile +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/README.md +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/dashboard.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/screens/term_dashboard.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/test_hello.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/__init__.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/conftest.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_term_dashboard_ui.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_tui_headless.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.445 → kiwi_code-0.0.447}/tests/test_unified_diff.py +0 -0
- {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.
|
|
3
|
+
Version: 0.0.447
|
|
4
4
|
Summary: A textual-based terminal user interface application
|
|
5
5
|
Project-URL: Homepage, https://meetkiwi.ai
|
|
6
|
-
|
|
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.
|
|
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
|
|
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"
|
|
@@ -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 =
|
|
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
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
271
|
-
|
|
270
|
+
class _FakePopen:
|
|
271
|
+
def __init__(self, pid: int = 12345):
|
|
272
|
+
self.pid = pid
|
|
273
|
+
self.returncode = 0
|
|
272
274
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
275
|
+
def wait(self, timeout: float | None = None):
|
|
276
|
+
# Immediately succeed (standalone path).
|
|
277
|
+
self.returncode = 0
|
|
278
|
+
return 0
|
|
276
279
|
|
|
277
|
-
|
|
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
|
|
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(
|