kiwi-code 0.0.441__tar.gz → 0.0.443__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 (68) hide show
  1. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/__init__.py +1 -1
  4. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_runtime/__init__.py +1 -1
  5. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_runtime/main.py +283 -32
  6. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/__init__.py +1 -1
  7. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/main.py +0 -37
  8. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/runtime_agent.py +22 -6
  9. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/term_dashboard.py +854 -115
  10. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/widgets.py +17 -1
  11. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/uv.lock +1 -1
  12. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/.github/workflows/publish.yml +0 -0
  13. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/.github/workflows/test.yml +0 -0
  14. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/.gitignore +0 -0
  15. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/.python-version +0 -0
  16. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/CLAUDE.md +0 -0
  17. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/Makefile +0 -0
  18. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/README.md +0 -0
  19. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/auth.py +0 -0
  20. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/checkpoints.py +0 -0
  21. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/cli.py +0 -0
  22. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/client.py +0 -0
  23. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/commands.py +0 -0
  24. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/logger.py +0 -0
  25. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/models.py +0 -0
  26. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/runtime_manager.py +0 -0
  27. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/server.py +0 -0
  28. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_cli/terminal_mode.py +0 -0
  29. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_runtime/__main__.py +0 -0
  30. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/inline_file_picker.py +0 -0
  31. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/random_words.py +0 -0
  32. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/__init__.py +0 -0
  33. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/attach_content.py +0 -0
  34. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/command_result.py +0 -0
  35. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/dashboard.py +0 -0
  36. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/detach_files.py +0 -0
  37. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/file_browser.py +0 -0
  38. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/help.py +0 -0
  39. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/id_picker.py +0 -0
  40. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/login.py +0 -0
  41. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  42. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  43. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/screens/slash_picker.py +0 -0
  44. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/slash_commands.py +0 -0
  45. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/status_words.py +0 -0
  46. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/term_app.py +0 -0
  47. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/src/kiwi_tui/worktrees.py +0 -0
  48. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/test_hello.py +0 -0
  49. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/__init__.py +0 -0
  50. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/conftest.py +0 -0
  51. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_batch_fs_tool.py +0 -0
  52. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_checkpoints.py +0 -0
  53. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_cli_help.py +0 -0
  54. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_copy_path_fs_tool.py +0 -0
  55. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_imports.py +0 -0
  56. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_read_file_streaming.py +0 -0
  57. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_reexec_kiwi.py +0 -0
  58. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_runtime_log_trimming.py +0 -0
  59. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_search_in_files_fs_tool.py +0 -0
  60. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_slash_commands.py +0 -0
  61. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_term_dashboard_ui.py +0 -0
  62. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_terminal_mode.py +0 -0
  63. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_tokens.py +0 -0
  64. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_tui_headless.py +0 -0
  65. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_tui_interactive_runtime.py +0 -0
  66. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_tui_palette.py +0 -0
  67. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_unified_diff.py +0 -0
  68. {kiwi_code-0.0.441 → kiwi_code-0.0.443}/tests/test_worktrees.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.441
3
+ Version: 0.0.443
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.441"
3
+ version = "0.0.443"
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"
@@ -1,3 +1,3 @@
1
1
  """Kiwi CLI - command-line interface and shared infrastructure modules."""
2
2
 
3
- __version__ = "0.0.441"
3
+ __version__ = "0.0.443"
@@ -1,3 +1,3 @@
1
1
  """Kiwi Runtime — terminal agent that connects to the server via WebSocket."""
2
2
 
3
- __version__ = "0.0.441"
3
+ __version__ = "0.0.443"
@@ -12,6 +12,7 @@ Usage:
12
12
  kiwi connect --server dev --scope full
13
13
  kiwi connect --server dev --allow /path/to/extra/dir
14
14
  """
15
+ from collections import deque
15
16
  from datetime import datetime
16
17
  import argparse
17
18
  import asyncio
@@ -24,6 +25,8 @@ import shlex
24
25
  import signal
25
26
  import subprocess
26
27
  import sys
28
+ import random
29
+ import time
27
30
  from pathlib import Path
28
31
  from typing import Any
29
32
  import shutil
@@ -3243,6 +3246,7 @@ async def run_command(
3243
3246
  if not ok:
3244
3247
  return {"stdout": "", "stderr": reason, "exit_code": 1}
3245
3248
 
3249
+ proc: asyncio.subprocess.Process | None = None
3246
3250
  try:
3247
3251
  cwd = allowed_dirs[0] if allowed_dirs else None
3248
3252
  proc = await asyncio.create_subprocess_shell(
@@ -3258,10 +3262,33 @@ async def run_command(
3258
3262
  stderr = stderr_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT_BYTES]
3259
3263
  return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode}
3260
3264
  except asyncio.TimeoutError:
3261
- proc.kill()
3262
- await proc.wait()
3265
+ if proc and proc.returncode is None:
3266
+ try:
3267
+ proc.kill()
3268
+ except ProcessLookupError:
3269
+ pass
3270
+ try:
3271
+ await proc.wait()
3272
+ except Exception:
3273
+ pass
3263
3274
  return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
3275
+ except asyncio.CancelledError:
3276
+ if proc and proc.returncode is None:
3277
+ try:
3278
+ proc.kill()
3279
+ except ProcessLookupError:
3280
+ pass
3281
+ try:
3282
+ await proc.wait()
3283
+ except Exception:
3284
+ pass
3285
+ raise
3264
3286
  except Exception as e:
3287
+ if proc and proc.returncode is None:
3288
+ try:
3289
+ proc.kill()
3290
+ except Exception:
3291
+ pass
3265
3292
  return {"stdout": "", "stderr": str(e), "exit_code": -1}
3266
3293
 
3267
3294
 
@@ -4199,16 +4226,152 @@ async def connect(
4199
4226
  allowed_dirs: list[str] | None = None,
4200
4227
  agent_id: str | None = None,
4201
4228
  consumer_id: str | None = None,
4229
+ *,
4230
+ max_retries: int = 12,
4231
+ ):
4232
+ ws_endpoint = f"{ws_url}/v1/terminal/ws/connect"
4233
+ retries = 0
4234
+
4235
+ # Shared state across reconnect attempts.
4236
+ pending_results: deque[dict[str, Any]] = deque()
4237
+ pending_lock = asyncio.Lock()
4238
+ pending_event = asyncio.Event()
4239
+
4240
+ command_queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(maxsize=64)
4241
+ cmd_state: dict[str, Any] = {"running": False, "command": None}
4242
+
4243
+ async def _command_worker() -> None:
4244
+ while True:
4245
+ request_id, command = await command_queue.get()
4246
+ result_msg: dict[str, Any] | None = None
4247
+ cmd_state["running"] = True
4248
+ cmd_state["command"] = command
4249
+ try:
4250
+ result = await run_command(
4251
+ command,
4252
+ mode=mode,
4253
+ allowed_dirs=allowed_dirs,
4254
+ )
4255
+ exit_code = 0
4256
+ try:
4257
+ exit_code = int(result.get("exit_code", 0))
4258
+ except Exception:
4259
+ exit_code = 0
4260
+ success = exit_code == 0
4261
+ status_msg = (
4262
+ f"{GREEN}OK{RESET}"
4263
+ if success
4264
+ else f"{RED}FAILED (exit {exit_code}){RESET}"
4265
+ )
4266
+ print_cmd_log(request_id, status_msg, success)
4267
+ result_msg = {
4268
+ "type": "result",
4269
+ "request_id": request_id,
4270
+ **result,
4271
+ }
4272
+ except asyncio.CancelledError:
4273
+ # Cancellation = runtime is shutting down. Let it propagate after cleanup.
4274
+ raise
4275
+ except Exception as e:
4276
+ print_cmd_log(request_id, f"{RED}FAILED{RESET}", False)
4277
+ result_msg = {
4278
+ "type": "result",
4279
+ "request_id": request_id,
4280
+ "stdout": "",
4281
+ "stderr": str(e),
4282
+ "exit_code": -1,
4283
+ }
4284
+ finally:
4285
+ cmd_state["running"] = False
4286
+ cmd_state["command"] = None
4287
+ try:
4288
+ command_queue.task_done()
4289
+ except Exception:
4290
+ pass
4291
+ if result_msg is not None:
4292
+ async with pending_lock:
4293
+ pending_results.append(result_msg)
4294
+ pending_event.set()
4295
+
4296
+ command_worker_task = asyncio.create_task(_command_worker())
4297
+ try:
4298
+ while True:
4299
+ attempt = retries + 1
4300
+ total_attempts = max_retries + 1
4301
+ print_status(
4302
+ "~",
4303
+ f"Connecting to {BOLD}{ws_endpoint}{RESET} {GREY}(attempt {attempt}/{total_attempts}){RESET}",
4304
+ C,
4305
+ )
4306
+ try:
4307
+ await _connect_once(
4308
+ ws_url,
4309
+ token,
4310
+ http_base_url=http_base_url,
4311
+ mode=mode,
4312
+ allowed_dirs=allowed_dirs,
4313
+ agent_id=agent_id,
4314
+ consumer_id=consumer_id,
4315
+ command_queue=command_queue,
4316
+ pending_results=pending_results,
4317
+ pending_lock=pending_lock,
4318
+ pending_event=pending_event,
4319
+ cmd_state=cmd_state,
4320
+ )
4321
+ return
4322
+ except asyncio.CancelledError:
4323
+ raise
4324
+ except websockets.exceptions.ConnectionClosed as e:
4325
+ print()
4326
+ print_status("!", f"Connection closed: {e}", YELLOW)
4327
+ except ConnectionRefusedError:
4328
+ print()
4329
+ print_status("x", f"Could not connect to {ws_endpoint}. Is the server running?", RED)
4330
+ except Exception as e:
4331
+ print()
4332
+ print_status("x", f"Error: {e}", RED)
4333
+
4334
+ if retries >= max_retries:
4335
+ print_status("x", f"Max reconnect attempts reached ({max_retries}).", RED)
4336
+ return
4337
+
4338
+ retries += 1
4339
+ base_delay = min(30.0, 1.5 * (2 ** (retries - 1)))
4340
+ delay = base_delay + random.uniform(0.0, 0.6)
4341
+ print_status(
4342
+ "~",
4343
+ f"Reconnecting in {delay:.1f}s... {GREY}(retry {retries}/{max_retries}){RESET}",
4344
+ YELLOW,
4345
+ )
4346
+ await asyncio.sleep(delay)
4347
+ finally:
4348
+ if command_worker_task and not command_worker_task.done():
4349
+ command_worker_task.cancel()
4350
+ await asyncio.gather(command_worker_task, return_exceptions=True)
4351
+
4352
+
4353
+ async def _connect_once(
4354
+ ws_url: str,
4355
+ token: str,
4356
+ http_base_url: str,
4357
+ mode: str = "restricted",
4358
+ allowed_dirs: list[str] | None = None,
4359
+ agent_id: str | None = None,
4360
+ consumer_id: str | None = None,
4361
+ *,
4362
+ command_queue: asyncio.Queue[tuple[str, str]],
4363
+ pending_results: deque[dict[str, Any]],
4364
+ pending_lock: asyncio.Lock,
4365
+ pending_event: asyncio.Event,
4366
+ cmd_state: dict[str, Any],
4202
4367
  ):
4203
- """Connect to the server WebSocket and process commands."""
4204
4368
  ws_endpoint = f"{ws_url}/v1/terminal/ws/connect"
4205
- print_status("~", f"Connecting to {BOLD}{ws_endpoint}{RESET}", C)
4206
4369
  background_tasks: set[asyncio.Task] = set()
4207
4370
  try:
4208
4371
  async with websockets.connect(
4209
4372
  ws_endpoint,
4210
4373
  ping_interval=20,
4211
- ping_timeout=60,
4374
+ ping_timeout=90,
4212
4375
  close_timeout=10,
4213
4376
  max_queue=64,
4214
4377
  ) as ws:
@@ -4262,34 +4425,114 @@ async def connect(
4262
4425
  chunked_writes: dict[str, ChunkedWriteState] = {}
4263
4426
  paired_msg_count = 0
4264
4427
  fs_message_types = { "read_file","write_file","append_file","replace_in_file","stat_file","list_dir","mkdir","move_path","copy_path", "delete_path","apply_unified_diff","replace_line_range","insert_at_line","search_in_file","search_in_files","batch","file_write_start","file_write_chunk","file_write_finish","file_write_abort"}
4428
+
4429
+ hb_debug_env = os.environ.get("KIWI_RUNTIME_DEBUG_HEARTBEAT", "").strip().lower()
4430
+ hb_debug = hb_debug_env not in ("", "0", "false", "no")
4431
+ hb_log_every_s = 5.0
4432
+ try:
4433
+ hb_log_every_s = float(os.environ.get("KIWI_RUNTIME_DEBUG_HEARTBEAT_INTERVAL", "5") or "5")
4434
+ except Exception:
4435
+ hb_log_every_s = 5.0
4436
+
4437
+ hb_ws_ping_env = os.environ.get("KIWI_RUNTIME_DEBUG_HEARTBEAT_WS_PING", "").strip().lower()
4438
+ hb_ws_ping = hb_ws_ping_env not in ("", "0", "false", "no")
4439
+ hb_ws_ping_timeout_s = 5.0
4440
+ try:
4441
+ hb_ws_ping_timeout_s = float(os.environ.get("KIWI_RUNTIME_DEBUG_HEARTBEAT_WS_PING_TIMEOUT", "5") or "5")
4442
+ except Exception:
4443
+ hb_ws_ping_timeout_s = 5.0
4444
+
4445
+ heartbeat_debug_task: asyncio.Task | None = None
4446
+ pending_sender_task: asyncio.Task | None = None
4447
+
4448
+ async def _pending_sender_loop() -> None:
4449
+ while True:
4450
+ item: dict[str, Any] | None = None
4451
+ async with pending_lock:
4452
+ if pending_results:
4453
+ item = pending_results.popleft()
4454
+
4455
+ if item is None:
4456
+ pending_event.clear()
4457
+ async with pending_lock:
4458
+ if pending_results:
4459
+ continue
4460
+ await pending_event.wait()
4461
+ continue
4462
+ try:
4463
+ await ws.send(json.dumps(item))
4464
+ except asyncio.CancelledError:
4465
+ raise
4466
+ except Exception:
4467
+ async with pending_lock:
4468
+ pending_results.appendleft(item)
4469
+ pending_event.set()
4470
+ raise
4471
+
4472
+ pending_sender_task = asyncio.create_task(_pending_sender_loop())
4473
+
4474
+ async def _heartbeat_debug_loop() -> None:
4475
+ while True:
4476
+ await asyncio.sleep(max(0.2, hb_log_every_s))
4477
+ if not bool(cmd_state.get("running")):
4478
+ continue
4479
+ qsize = 0
4480
+ try:
4481
+ qsize = command_queue.qsize()
4482
+ except Exception:
4483
+ qsize = 0
4484
+ msg = f"Heartbeat(debug) | busy=True queue={qsize}"
4485
+ cmd = cmd_state.get("command")
4486
+ if cmd:
4487
+ cleaned = str(cmd).strip().replace("\n", " ")
4488
+ if len(cleaned) > 60:
4489
+ cleaned = cleaned[:57] + "..."
4490
+ msg += f" | cmd={cleaned}"
4491
+ if hb_ws_ping:
4492
+ try:
4493
+ waiter = await ws.ping()
4494
+ await asyncio.wait_for(waiter, timeout=hb_ws_ping_timeout_s)
4495
+ msg += " | ws_pong=ok"
4496
+ except Exception:
4497
+ msg += " | ws_pong=fail"
4498
+ print_status("·", msg, GREY)
4499
+
4500
+ if hb_debug:
4501
+ heartbeat_debug_task = asyncio.create_task(_heartbeat_debug_loop())
4502
+
4265
4503
  try:
4266
4504
  async for message in ws:
4267
4505
  msg = json.loads(message)
4268
4506
  msg_type = msg.get("type")
4269
4507
 
4508
+ if pending_sender_task and pending_sender_task.done():
4509
+ try:
4510
+ exc = pending_sender_task.exception()
4511
+ except asyncio.CancelledError:
4512
+ exc = None
4513
+ if exc:
4514
+ raise exc
4515
+ raise RuntimeError("Pending sender exited unexpectedly")
4516
+
4517
+
4270
4518
  if msg_type == "command":
4271
4519
  request_id = msg.get("request_id", "")
4272
4520
  command = msg.get("command", "")
4273
4521
  print_cmd_log(request_id, f"$ {BOLD}{command}{RESET}")
4274
4522
 
4275
- result = await run_command(
4276
- command,
4277
- mode=mode,
4278
- allowed_dirs=allowed_dirs,
4279
- )
4280
- exit_code = result["exit_code"]
4281
- success = exit_code == 0
4282
- status_text = (
4283
- f"{GREEN}OK{RESET}" if success
4284
- else f"{RED}FAILED (exit {exit_code}){RESET}"
4285
- )
4286
- print_cmd_log(request_id, status_text, success)
4523
+ try:
4524
+ command_queue.put_nowait((str(request_id), str(command)))
4525
+ except asyncio.QueueFull:
4526
+ err = "Runtime command queue full; please retry."
4527
+ print_cmd_log(request_id, f"{RED}FAILED{RESET}", False)
4528
+ await ws.send(json.dumps({
4529
+ "type": "result",
4530
+ "request_id": request_id,
4531
+ "stdout": "",
4532
+ "stderr": err,
4533
+ "exit_code": 1,
4534
+ }))
4287
4535
 
4288
- await ws.send(json.dumps({
4289
- "type": "result",
4290
- "request_id": request_id,
4291
- **result,
4292
- }))
4293
4536
 
4294
4537
  elif msg_type == "interactive":
4295
4538
  request_id = msg.get("request_id", "")
@@ -4304,7 +4547,12 @@ async def connect(
4304
4547
  )
4305
4548
  exit_code = result["exit_code"]
4306
4549
  success = exit_code == 0
4307
- print_cmd_log(request_id, f"{'OK' if success else f'FAILED (exit {exit_code})'}", success)
4550
+ status_msg = (
4551
+ f"{GREEN}OK{RESET}"
4552
+ if success
4553
+ else f"{RED}FAILED (exit {exit_code}){RESET}"
4554
+ )
4555
+ print_cmd_log(request_id, status_msg, success)
4308
4556
 
4309
4557
  await ws.send(json.dumps({
4310
4558
  "type": "result",
@@ -4557,9 +4805,17 @@ async def connect(
4557
4805
  elif msg_type == "ping":
4558
4806
  await ws.send(json.dumps({"type": "pong"}))
4559
4807
 
4808
+
4560
4809
  else:
4561
4810
  print_status("?", f"Unknown message: {msg_type}", YELLOW)
4562
4811
  finally:
4812
+ if heartbeat_debug_task and not heartbeat_debug_task.done():
4813
+ heartbeat_debug_task.cancel()
4814
+ await asyncio.gather(heartbeat_debug_task, return_exceptions=True)
4815
+
4816
+ if pending_sender_task and not pending_sender_task.done():
4817
+ pending_sender_task.cancel()
4818
+ await asyncio.gather(pending_sender_task, return_exceptions=True)
4563
4819
  bg_tasks = list(background_tasks)
4564
4820
  for task in bg_tasks:
4565
4821
  task.cancel()
@@ -4584,15 +4840,10 @@ async def connect(
4584
4840
  except Exception:
4585
4841
  pass
4586
4842
 
4587
- except websockets.exceptions.ConnectionClosed as e:
4588
- print()
4589
- print_status("!", f"Connection closed: {e}", YELLOW)
4590
- except ConnectionRefusedError:
4591
- print()
4592
- print_status("x", f"Could not connect to {ws_endpoint}. Is the server running?", RED)
4593
- except Exception as e:
4594
- print()
4595
- print_status("x", f"Error: {e}", RED)
4843
+ except asyncio.CancelledError:
4844
+ raise
4845
+ except Exception:
4846
+ raise
4596
4847
 
4597
4848
 
4598
4849
  def main():
@@ -1,3 +1,3 @@
1
1
  """Autobots TUI - A textual-based terminal user interface."""
2
2
 
3
- __version__ = "0.0.441"
3
+ __version__ = "0.0.443"
@@ -929,43 +929,6 @@ class AutobotsTUI(App):
929
929
  def action_quit(self) -> None:
930
930
  """Quit the application."""
931
931
  self.request_quit()
932
- try:
933
- all_rt = list_known_runtimes()
934
- except Exception:
935
- all_rt = []
936
-
937
- alive = [r for r in all_rt if r.get("alive") and r.get("pid")]
938
- if not alive:
939
- self._final_exit()
940
- return
941
-
942
- rows: list[RuntimeRow] = []
943
- for r in alive:
944
- try:
945
- meta = r.get("meta") or {}
946
- name = None
947
- if isinstance(meta, dict):
948
- name = meta.get("run_name")
949
- rows.append(
950
- RuntimeRow(
951
- kind=str(r.get("kind")),
952
- runtime_id=str(r.get("id")),
953
- pid=int(r.get("pid")),
954
- alive=True,
955
- log_path=str(r.get("log_path")),
956
- name=name if isinstance(name, str) else None,
957
- kill=False,
958
- )
959
- )
960
- except Exception:
961
- continue
962
-
963
- # Show interactive prompt; the callback will exit.
964
- self.push_screen(RuntimeCleanupScreen(rows), callback=self._on_runtime_cleanup_done)
965
-
966
- def action_quit(self) -> None:
967
- """Quit the application."""
968
- self.request_quit()
969
932
 
970
933
 
971
934
  def _run_tui(runtime_args: RuntimeConnectArgs | None = None):
@@ -242,14 +242,11 @@ def _pid_matches_create_time(pid: int, expected_create_time: float | None) -> bo
242
242
 
243
243
 
244
244
  def _runtime_valid(pid: int, *, meta: dict[str, Any] | None, log_path: Path) -> bool:
245
- """Determine whether an on-disk runtime record is still usable."""
246
245
  if not pid:
247
246
  return False
248
247
  if not _pid_alive(pid):
249
248
  return False
250
- if not _pid_is_kiwi_runtime(pid):
251
- return False
252
- expected_ct = None
249
+ expected_ct: float | None = None
253
250
  try:
254
251
  if isinstance(meta, dict):
255
252
  expected_ct = meta.get("create_time")
@@ -259,10 +256,21 @@ def _runtime_valid(pid: int, *, meta: dict[str, Any] | None, log_path: Path) ->
259
256
  expected_ct = None
260
257
  if not _pid_matches_create_time(pid, expected_ct if isinstance(expected_ct, (int, float)) else None):
261
258
  return False
262
- # If logs show a disconnect, treat the runtime as invalid so it can be restarted.
259
+
263
260
  if _log_indicates_disconnected(log_path):
264
261
  return False
265
- return True
262
+
263
+ if _pid_is_kiwi_runtime(pid):
264
+ return True
265
+
266
+ if expected_ct is not None and isinstance(meta, dict) and meta.get("tui_managed"):
267
+ try:
268
+ exe = psutil.Process(pid).exe().lower()
269
+ if "python" in exe:
270
+ return True
271
+ except Exception:
272
+ pass
273
+ return False
266
274
 
267
275
 
268
276
  def _pid_alive(pid: int) -> bool:
@@ -826,6 +834,14 @@ def list_known_runtimes() -> list[dict[str, Any]]:
826
834
  except Exception:
827
835
  meta = {}
828
836
 
837
+ if pid is None and isinstance(meta, dict):
838
+ try:
839
+ mpid = meta.get("pid")
840
+ if isinstance(mpid, (int, float, str)) and str(mpid).strip():
841
+ pid = int(mpid)
842
+ except Exception:
843
+ pid = None
844
+
829
845
  # If a pending runtime was soft-bound to a run, skip it to avoid duplicate
830
846
  # entries in cleanup screens.
831
847
  if kind == "pending" and meta.get("bound_run_id"):