kiwi-code 0.0.40__tar.gz → 0.0.42__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.40 → kiwi_code-0.0.42}/PKG-INFO +1 -1
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/pyproject.toml +1 -1
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/main.py +226 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/main.py +54 -4
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/runtime_agent.py +27 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/dashboard.py +251 -65
- kiwi_code-0.0.42/src/kiwi_tui/screens/runtime_logs.py +473 -0
- kiwi_code-0.0.42/tests/test_tui_interactive_runtime.py +387 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/uv.lock +1 -1
- kiwi_code-0.0.40/src/kiwi_tui/screens/runtime_logs.py +0 -186
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/.gitignore +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/.python-version +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/CLAUDE.md +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/Makefile +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/README.md +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/test_hello.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/__init__.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/conftest.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_tui_headless.py +0 -0
- {kiwi_code-0.0.40 → kiwi_code-0.0.42}/tests/test_tui_palette.py +0 -0
|
@@ -1713,6 +1713,7 @@ async def run_interactive_command(
|
|
|
1713
1713
|
command: str,
|
|
1714
1714
|
mode: str = "restricted",
|
|
1715
1715
|
allowed_dirs: list[str] | None = None,
|
|
1716
|
+
request_id: str | None = None,
|
|
1716
1717
|
) -> dict:
|
|
1717
1718
|
"""Execute a command with the user's terminal attached for interactive I/O."""
|
|
1718
1719
|
if mode == "restricted" and allowed_dirs:
|
|
@@ -1720,6 +1721,16 @@ async def run_interactive_command(
|
|
|
1720
1721
|
if not ok:
|
|
1721
1722
|
return {"stdout": "", "stderr": reason, "exit_code": 1}
|
|
1722
1723
|
|
|
1724
|
+
control_dir = _tui_managed_control_dir()
|
|
1725
|
+
if control_dir is not None:
|
|
1726
|
+
return await run_tui_managed_interactive_command(
|
|
1727
|
+
command,
|
|
1728
|
+
request_id=request_id or str(uuid.uuid4()),
|
|
1729
|
+
control_dir=control_dir,
|
|
1730
|
+
mode=mode,
|
|
1731
|
+
allowed_dirs=allowed_dirs,
|
|
1732
|
+
)
|
|
1733
|
+
|
|
1723
1734
|
try:
|
|
1724
1735
|
cwd = allowed_dirs[0] if allowed_dirs else None
|
|
1725
1736
|
print(f"\n {CB}{'─' * 50}{RESET}")
|
|
@@ -2262,6 +2273,220 @@ def _ensure_windows_persistent_completion(data: str) -> str:
|
|
|
2262
2273
|
|
|
2263
2274
|
return f"{base} & echo {_SENTINEL}!ERRORLEVEL!{_SENTINEL}\r\n"
|
|
2264
2275
|
|
|
2276
|
+
|
|
2277
|
+
def _tui_managed_control_dir() -> Path | None:
|
|
2278
|
+
raw_flag = os.environ.get("KIWI_TUI_MANAGED", "").strip().lower()
|
|
2279
|
+
if raw_flag not in {"1", "true", "yes", "on"}:
|
|
2280
|
+
return None
|
|
2281
|
+
raw_path = os.environ.get("KIWI_RUNTIME_CONTROL_DIR", "").strip()
|
|
2282
|
+
if not raw_path:
|
|
2283
|
+
return None
|
|
2284
|
+
return Path(raw_path)
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
class _LocalInteractivePTYBridge:
|
|
2288
|
+
def __init__(self, *, control_dir: Path, request_id: str, command: str) -> None:
|
|
2289
|
+
self.control_dir = control_dir
|
|
2290
|
+
self.request_id = request_id
|
|
2291
|
+
self.command = command
|
|
2292
|
+
self.session_dir = control_dir / "interactive"
|
|
2293
|
+
self.status_path = self.session_dir / "status.json"
|
|
2294
|
+
self.input_path = self.session_dir / "input.jsonl"
|
|
2295
|
+
self.output_path = self.session_dir / "output.log"
|
|
2296
|
+
self._input_offset = 0
|
|
2297
|
+
self._input_buffer = ""
|
|
2298
|
+
self.exit_code: int | None = None
|
|
2299
|
+
self.error: str | None = None
|
|
2300
|
+
self._done = asyncio.Event()
|
|
2301
|
+
|
|
2302
|
+
def activate(self) -> None:
|
|
2303
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
2304
|
+
self.output_path.write_text("", encoding="utf-8")
|
|
2305
|
+
self.input_path.write_text("", encoding="utf-8")
|
|
2306
|
+
self._input_offset = 0
|
|
2307
|
+
self._input_buffer = ""
|
|
2308
|
+
self._write_status(
|
|
2309
|
+
active=True,
|
|
2310
|
+
request_id=self.request_id,
|
|
2311
|
+
command=self.command,
|
|
2312
|
+
output_path=str(self.output_path),
|
|
2313
|
+
input_path=str(self.input_path),
|
|
2314
|
+
started_at=datetime.now().isoformat(),
|
|
2315
|
+
ended_at=None,
|
|
2316
|
+
exit_code=None,
|
|
2317
|
+
error=None,
|
|
2318
|
+
)
|
|
2319
|
+
|
|
2320
|
+
def _write_status(self, **updates: Any) -> None:
|
|
2321
|
+
status: dict[str, Any] = {}
|
|
2322
|
+
if self.status_path.exists():
|
|
2323
|
+
try:
|
|
2324
|
+
status = json.loads(self.status_path.read_text(encoding="utf-8") or "{}")
|
|
2325
|
+
except Exception:
|
|
2326
|
+
status = {}
|
|
2327
|
+
status.update(updates)
|
|
2328
|
+
self.status_path.write_text(json.dumps(status, indent=2, default=str), encoding="utf-8")
|
|
2329
|
+
|
|
2330
|
+
def append_output(self, data: str) -> None:
|
|
2331
|
+
if not data:
|
|
2332
|
+
return
|
|
2333
|
+
with open(self.output_path, "a", encoding="utf-8", errors="replace") as fp:
|
|
2334
|
+
fp.write(data)
|
|
2335
|
+
fp.flush()
|
|
2336
|
+
|
|
2337
|
+
async def send(self, payload: str) -> None:
|
|
2338
|
+
msg = json.loads(payload)
|
|
2339
|
+
msg_type = msg.get("type")
|
|
2340
|
+
if msg_type == "pty_started":
|
|
2341
|
+
self._write_status(active=True, pty_started=True, session_id=msg.get("session_id"))
|
|
2342
|
+
return
|
|
2343
|
+
if msg_type == "pty_output":
|
|
2344
|
+
self.append_output(str(msg.get("data", "")))
|
|
2345
|
+
return
|
|
2346
|
+
if msg_type == "pty_exited":
|
|
2347
|
+
try:
|
|
2348
|
+
self.exit_code = int(msg.get("exit_code", -1))
|
|
2349
|
+
except Exception:
|
|
2350
|
+
self.exit_code = -1
|
|
2351
|
+
self._write_status(
|
|
2352
|
+
active=False,
|
|
2353
|
+
ended_at=datetime.now().isoformat(),
|
|
2354
|
+
exit_code=self.exit_code,
|
|
2355
|
+
)
|
|
2356
|
+
self._done.set()
|
|
2357
|
+
return
|
|
2358
|
+
if msg_type == "pty_error":
|
|
2359
|
+
self.error = str(msg.get("error", ""))
|
|
2360
|
+
if self.error:
|
|
2361
|
+
self.append_output(f"\r\n[PTY error] {self.error}\r\n")
|
|
2362
|
+
self._write_status(
|
|
2363
|
+
active=False,
|
|
2364
|
+
ended_at=datetime.now().isoformat(),
|
|
2365
|
+
exit_code=-1,
|
|
2366
|
+
error=self.error,
|
|
2367
|
+
)
|
|
2368
|
+
self._done.set()
|
|
2369
|
+
|
|
2370
|
+
def _read_new_events(self) -> list[dict[str, Any]]:
|
|
2371
|
+
if not self.input_path.exists():
|
|
2372
|
+
return []
|
|
2373
|
+
try:
|
|
2374
|
+
with open(self.input_path, "r", encoding="utf-8", errors="replace") as fp:
|
|
2375
|
+
fp.seek(self._input_offset)
|
|
2376
|
+
chunk = fp.read()
|
|
2377
|
+
self._input_offset = fp.tell()
|
|
2378
|
+
except Exception:
|
|
2379
|
+
return []
|
|
2380
|
+
|
|
2381
|
+
if not chunk:
|
|
2382
|
+
return []
|
|
2383
|
+
|
|
2384
|
+
events: list[dict[str, Any]] = []
|
|
2385
|
+
data = self._input_buffer + chunk
|
|
2386
|
+
self._input_buffer = ""
|
|
2387
|
+
for line in data.splitlines(keepends=True):
|
|
2388
|
+
if not line.endswith("\n"):
|
|
2389
|
+
self._input_buffer = line
|
|
2390
|
+
continue
|
|
2391
|
+
raw = line.strip()
|
|
2392
|
+
if not raw:
|
|
2393
|
+
continue
|
|
2394
|
+
try:
|
|
2395
|
+
parsed = json.loads(raw)
|
|
2396
|
+
except Exception:
|
|
2397
|
+
continue
|
|
2398
|
+
if isinstance(parsed, dict):
|
|
2399
|
+
events.append(parsed)
|
|
2400
|
+
return events
|
|
2401
|
+
|
|
2402
|
+
async def pump_inputs(self, pty_proc: Any) -> None:
|
|
2403
|
+
while not self._done.is_set():
|
|
2404
|
+
if getattr(getattr(pty_proc, "proc", None), "returncode", None) is not None:
|
|
2405
|
+
break
|
|
2406
|
+
for event in self._read_new_events():
|
|
2407
|
+
event_type = str(event.get("type", "")).strip().lower()
|
|
2408
|
+
if event_type == "input":
|
|
2409
|
+
data = str(event.get("data", ""))
|
|
2410
|
+
if data:
|
|
2411
|
+
await pty_proc.send_input(data)
|
|
2412
|
+
elif event_type == "interrupt":
|
|
2413
|
+
await pty_proc.interrupt()
|
|
2414
|
+
elif event_type == "close":
|
|
2415
|
+
await pty_proc.close()
|
|
2416
|
+
await asyncio.sleep(0.1)
|
|
2417
|
+
|
|
2418
|
+
async def wait(self) -> int:
|
|
2419
|
+
await self._done.wait()
|
|
2420
|
+
return self.exit_code if self.exit_code is not None else -1
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
async def run_tui_managed_interactive_command(
|
|
2424
|
+
command: str,
|
|
2425
|
+
*,
|
|
2426
|
+
request_id: str,
|
|
2427
|
+
control_dir: Path,
|
|
2428
|
+
mode: str = "restricted",
|
|
2429
|
+
allowed_dirs: list[str] | None = None,
|
|
2430
|
+
) -> dict:
|
|
2431
|
+
if mode == "restricted" and allowed_dirs:
|
|
2432
|
+
ok, reason = validate_command(command, allowed_dirs)
|
|
2433
|
+
if not ok:
|
|
2434
|
+
return {"stdout": "", "stderr": reason, "exit_code": 1}
|
|
2435
|
+
|
|
2436
|
+
cwd = allowed_dirs[0] if allowed_dirs else None
|
|
2437
|
+
bridge = _LocalInteractivePTYBridge(control_dir=control_dir, request_id=request_id, command=command)
|
|
2438
|
+
bridge.activate()
|
|
2439
|
+
bridge.append_output(f"$ {command}\r\n")
|
|
2440
|
+
|
|
2441
|
+
session_id = request_id or str(uuid.uuid4())
|
|
2442
|
+
input_task: asyncio.Task[Any] | None = None
|
|
2443
|
+
pty_proc = None
|
|
2444
|
+
try:
|
|
2445
|
+
if IS_WINDOWS:
|
|
2446
|
+
pty_proc = PipeProcess(
|
|
2447
|
+
session_id,
|
|
2448
|
+
command,
|
|
2449
|
+
120,
|
|
2450
|
+
40,
|
|
2451
|
+
mode=mode,
|
|
2452
|
+
allowed_dirs=allowed_dirs,
|
|
2453
|
+
)
|
|
2454
|
+
else:
|
|
2455
|
+
pty_proc = PTYProcess(session_id, command, 120, 40)
|
|
2456
|
+
await pty_proc.start(bridge, cwd=cwd)
|
|
2457
|
+
input_task = asyncio.create_task(bridge.pump_inputs(pty_proc))
|
|
2458
|
+
exit_code = await bridge.wait()
|
|
2459
|
+
return {
|
|
2460
|
+
"stdout": "(interactive session completed)",
|
|
2461
|
+
"stderr": "",
|
|
2462
|
+
"exit_code": exit_code,
|
|
2463
|
+
}
|
|
2464
|
+
except Exception as e:
|
|
2465
|
+
message = str(e)
|
|
2466
|
+
bridge.append_output(f"\r\n[Interactive error] {message}\r\n")
|
|
2467
|
+
bridge._write_status(
|
|
2468
|
+
active=False,
|
|
2469
|
+
ended_at=datetime.now().isoformat(),
|
|
2470
|
+
exit_code=-1,
|
|
2471
|
+
error=message,
|
|
2472
|
+
)
|
|
2473
|
+
return {"stdout": "", "stderr": message, "exit_code": -1}
|
|
2474
|
+
finally:
|
|
2475
|
+
if input_task and not input_task.done():
|
|
2476
|
+
input_task.cancel()
|
|
2477
|
+
try:
|
|
2478
|
+
await input_task
|
|
2479
|
+
except asyncio.CancelledError:
|
|
2480
|
+
pass
|
|
2481
|
+
except Exception:
|
|
2482
|
+
pass
|
|
2483
|
+
if pty_proc is not None:
|
|
2484
|
+
try:
|
|
2485
|
+
await pty_proc.close()
|
|
2486
|
+
except Exception:
|
|
2487
|
+
pass
|
|
2488
|
+
|
|
2489
|
+
|
|
2265
2490
|
def _zip_directory(resolved, tmp_path, mode, allowed_dirs):
|
|
2266
2491
|
"""Synchronous helper to zip a directory (run via asyncio.to_thread)."""
|
|
2267
2492
|
import zipfile
|
|
@@ -2520,6 +2745,7 @@ async def connect(
|
|
|
2520
2745
|
command,
|
|
2521
2746
|
mode=mode,
|
|
2522
2747
|
allowed_dirs=allowed_dirs,
|
|
2748
|
+
request_id=request_id,
|
|
2523
2749
|
)
|
|
2524
2750
|
exit_code = result["exit_code"]
|
|
2525
2751
|
success = exit_code == 0
|
|
@@ -610,11 +610,12 @@ class AutobotsTUI(App):
|
|
|
610
610
|
# runtime_manager.save_refresh_token(tokens.refresh_token)
|
|
611
611
|
|
|
612
612
|
def _refresh_token_if_needed(self, force: bool = False) -> bool:
|
|
613
|
-
"""Synchronize auth state from disk and refresh
|
|
613
|
+
"""Synchronize auth state from disk and refresh when needed.
|
|
614
614
|
|
|
615
615
|
Args:
|
|
616
|
-
force: If True,
|
|
617
|
-
the
|
|
616
|
+
force: If True, attempt a refresh-token exchange immediately even if
|
|
617
|
+
the current access token does not appear expired on disk. This is
|
|
618
|
+
used after an HTTP 401/403 from the server.
|
|
618
619
|
"""
|
|
619
620
|
with self.token_manager.file_lock():
|
|
620
621
|
tokens = self.token_manager.load_tokens()
|
|
@@ -622,7 +623,7 @@ class AutobotsTUI(App):
|
|
|
622
623
|
logger.debug("No tokens to refresh")
|
|
623
624
|
return False
|
|
624
625
|
|
|
625
|
-
if not tokens.is_expired():
|
|
626
|
+
if not force and not tokens.is_expired():
|
|
626
627
|
logger.debug("Token on disk is valid; synchronizing in-memory auth state")
|
|
627
628
|
if getattr(tokens, "access_token", None):
|
|
628
629
|
self.autobots_client.update_token(tokens.access_token)
|
|
@@ -683,6 +684,55 @@ class AutobotsTUI(App):
|
|
|
683
684
|
except Exception:
|
|
684
685
|
pass
|
|
685
686
|
|
|
687
|
+
def _handle_auth_expired(self, reason: str | None = None) -> None:
|
|
688
|
+
"""Clear auth state and return the user to the login screen.
|
|
689
|
+
|
|
690
|
+
Used when the server rejects the current session (401/403) and a forced
|
|
691
|
+
token refresh cannot recover it.
|
|
692
|
+
"""
|
|
693
|
+
logger.warning(f"Authentication expired; redirecting to login. Reason: {reason or 'unknown'}")
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
screen = self.screen
|
|
697
|
+
if hasattr(screen, "reset_for_auth_expired"):
|
|
698
|
+
screen.reset_for_auth_expired()
|
|
699
|
+
except Exception:
|
|
700
|
+
pass
|
|
701
|
+
|
|
702
|
+
timer = getattr(self, "_token_refresh_timer", None)
|
|
703
|
+
if timer is not None:
|
|
704
|
+
try:
|
|
705
|
+
timer.stop()
|
|
706
|
+
except Exception:
|
|
707
|
+
pass
|
|
708
|
+
self._token_refresh_timer = None
|
|
709
|
+
|
|
710
|
+
self.pending_runtime_id = None
|
|
711
|
+
self.token_manager.clear_tokens()
|
|
712
|
+
self._kiwi_token = None
|
|
713
|
+
|
|
714
|
+
self.autobots_client = AutobotsClientWrapper(
|
|
715
|
+
base_url=self.config.backend_url,
|
|
716
|
+
api_key=self.config.api_key,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
try:
|
|
720
|
+
already_on_login = type(self.screen).__name__ == "LoginScreen"
|
|
721
|
+
except Exception:
|
|
722
|
+
already_on_login = False
|
|
723
|
+
|
|
724
|
+
if not already_on_login:
|
|
725
|
+
try:
|
|
726
|
+
self.switch_screen("login")
|
|
727
|
+
except IndexError:
|
|
728
|
+
self.push_screen("login")
|
|
729
|
+
|
|
730
|
+
self.notify(
|
|
731
|
+
reason or "Your session expired. Please log in again.",
|
|
732
|
+
severity="error",
|
|
733
|
+
title="Authentication required",
|
|
734
|
+
)
|
|
735
|
+
|
|
686
736
|
def action_logout(self) -> None:
|
|
687
737
|
"""Logout user and return to login screen."""
|
|
688
738
|
logger.info("User logout requested")
|
|
@@ -57,6 +57,7 @@ class RuntimeConnectArgs:
|
|
|
57
57
|
RUNTIMES_DIR = Path.home() / ".kiwi" / "runtimes"
|
|
58
58
|
BY_RUN_DIR = RUNTIMES_DIR / "by-run"
|
|
59
59
|
PENDING_DIR = RUNTIMES_DIR / "pending"
|
|
60
|
+
CONTROL_DIR = RUNTIMES_DIR / "control"
|
|
60
61
|
|
|
61
62
|
|
|
62
63
|
# Runtime log truncation
|
|
@@ -282,6 +283,11 @@ def _runtime_dir_for_pending(pending_id: str) -> Path:
|
|
|
282
283
|
d.mkdir(parents=True, exist_ok=True)
|
|
283
284
|
return d
|
|
284
285
|
|
|
286
|
+
def _control_dir_for_runtime(control_id: str) -> Path:
|
|
287
|
+
d = CONTROL_DIR / control_id
|
|
288
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
return d
|
|
290
|
+
|
|
285
291
|
|
|
286
292
|
def pid_path_for_run(run_id: str) -> Path:
|
|
287
293
|
return _runtime_dir_for_run(run_id) / "pid"
|
|
@@ -336,6 +342,19 @@ def meta_path_for_pending(pending_id: str) -> Path:
|
|
|
336
342
|
return _runtime_dir_for_pending(pending_id) / "meta.json"
|
|
337
343
|
|
|
338
344
|
|
|
345
|
+
def control_dir_from_meta(meta_path: Path) -> Path | None:
|
|
346
|
+
if not meta_path.exists():
|
|
347
|
+
return None
|
|
348
|
+
try:
|
|
349
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8") or "{}")
|
|
350
|
+
except Exception:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
control_dir = meta.get("control_dir")
|
|
354
|
+
if isinstance(control_dir, str) and control_dir.strip():
|
|
355
|
+
return Path(control_dir)
|
|
356
|
+
return None
|
|
357
|
+
|
|
339
358
|
def get_running_pid_for_run(run_id: str) -> int | None:
|
|
340
359
|
pid = _read_pid(pid_path_for_run(run_id))
|
|
341
360
|
if not pid:
|
|
@@ -434,6 +453,11 @@ def _spawn_runtime(
|
|
|
434
453
|
env.setdefault("PYTHONUTF8", "1")
|
|
435
454
|
env.setdefault("PYTHONIOENCODING", "utf-8:replace")
|
|
436
455
|
|
|
456
|
+
control_id = str(uuid.uuid4())
|
|
457
|
+
control_dir = _control_dir_for_runtime(control_id)
|
|
458
|
+
env["KIWI_TUI_MANAGED"] = "1"
|
|
459
|
+
env["KIWI_RUNTIME_CONTROL_DIR"] = str(control_dir)
|
|
460
|
+
|
|
437
461
|
try:
|
|
438
462
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
439
463
|
log_fp = open(log_path, "a", encoding="utf-8", errors="replace")
|
|
@@ -471,6 +495,9 @@ def _spawn_runtime(
|
|
|
471
495
|
"allow": list(args.allow_dirs),
|
|
472
496
|
"email": args.email,
|
|
473
497
|
"pid": proc.pid,
|
|
498
|
+
"tui_managed": True,
|
|
499
|
+
"control_id": control_id,
|
|
500
|
+
"control_dir": str(control_dir),
|
|
474
501
|
}
|
|
475
502
|
try:
|
|
476
503
|
meta["create_time"] = psutil.Process(proc.pid).create_time()
|