kiwi-code 0.0.36__tar.gz → 0.0.37__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 (53) hide show
  1. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/auth.py +14 -13
  4. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/cli.py +4 -1
  5. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/client.py +3 -2
  6. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_runtime/main.py +29 -4
  7. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/main.py +74 -33
  8. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/uv.lock +1 -1
  9. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/.github/workflows/publish.yml +0 -0
  10. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/.github/workflows/test.yml +0 -0
  11. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/.gitignore +0 -0
  12. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/.python-version +0 -0
  13. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/CLAUDE.md +0 -0
  14. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/Makefile +0 -0
  15. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/README.md +0 -0
  16. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/__init__.py +0 -0
  17. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/commands.py +0 -0
  18. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/logger.py +0 -0
  19. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/models.py +0 -0
  20. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/runtime_manager.py +0 -0
  21. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_cli/server.py +0 -0
  22. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_runtime/__init__.py +0 -0
  23. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_runtime/__main__.py +0 -0
  24. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  25. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  26. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/__init__.py +0 -0
  27. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/inline_file_picker.py +0 -0
  28. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/random_words.py +0 -0
  29. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/runtime_agent.py +0 -0
  30. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/__init__.py +0 -0
  31. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/attach_content.py +0 -0
  32. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/command_result.py +0 -0
  33. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/dashboard.py +0 -0
  34. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/file_browser.py +0 -0
  35. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/help.py +0 -0
  36. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/id_picker.py +0 -0
  37. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/login.py +0 -0
  38. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  39. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  40. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/screens/slash_picker.py +0 -0
  41. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/slash_commands.py +0 -0
  42. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/status_words.py +0 -0
  43. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/src/kiwi_tui/widgets.py +0 -0
  44. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/test_hello.py +0 -0
  45. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/__init__.py +0 -0
  46. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/conftest.py +0 -0
  47. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/test_cli_help.py +0 -0
  48. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/test_imports.py +0 -0
  49. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/test_reexec_kiwi.py +0 -0
  50. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/test_runtime_log_trimming.py +0 -0
  51. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/test_tokens.py +0 -0
  52. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/test_tui_headless.py +0 -0
  53. {kiwi_code-0.0.36 → kiwi_code-0.0.37}/tests/test_tui_palette.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.36
3
+ Version: 0.0.37
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.36"
3
+ version = "0.0.37"
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"
@@ -117,25 +117,26 @@ class TokenManager:
117
117
  return None
118
118
 
119
119
  def clear_tokens(self) -> None:
120
- """Clear stored tokens."""
121
- self._tokens = None
120
+ """Clear stored tokens under the same lock used for refresh/write flows."""
121
+ with self.file_lock():
122
+ self._tokens = None
122
123
 
123
- if self.token_path.exists():
124
- try:
125
- self.token_path.unlink()
126
- logger.info("Authentication tokens cleared")
127
- except Exception as e:
128
- logger.error(f"Failed to clear tokens: {e}")
124
+ if self.token_path.exists():
125
+ try:
126
+ self.token_path.unlink()
127
+ logger.info("Authentication tokens cleared")
128
+ except Exception as e:
129
+ logger.error(f"Failed to clear tokens: {e}")
129
130
 
130
131
  @property
131
132
  def tokens(self) -> Optional[AuthTokens]:
132
- """Get current tokens.
133
+ """Get current tokens from disk.
133
134
 
134
- Returns:
135
- Current AuthTokens or None
135
+ In multiprocess mode the shared token file may change at any time, so
136
+ callers should observe the latest on-disk state rather than a long-lived
137
+ in-memory cache.
136
138
  """
137
- if self._tokens is None:
138
- self._tokens = self.load_tokens()
139
+ self._tokens = self.load_tokens()
139
140
  return self._tokens
140
141
 
141
142
  def is_authenticated(self) -> bool:
@@ -82,13 +82,16 @@ def _force_refresh_tokens(tm: TokenManager) -> bool:
82
82
  tokens = tm.load_tokens()
83
83
  if not tokens:
84
84
  return False
85
+ access = getattr(tokens, "access_token", None)
86
+ if access and not tokens.is_expired():
87
+ return True
85
88
  refresh = getattr(tokens, "refresh_token", None)
86
89
  if not refresh:
87
90
  return False
88
91
 
89
92
  wrapper = AutobotsClientWrapper(
90
93
  base_url=_backend_url(),
91
- access_token=getattr(tokens, "access_token", None),
94
+ access_token=access,
92
95
  )
93
96
  ok, new_tokens, _msg = wrapper.refresh_token(str(refresh))
94
97
  if ok and new_tokens:
@@ -183,8 +183,9 @@ class AutobotsClientWrapper:
183
183
  # even if `refresh_token` is provided as a query param.
184
184
  url = f"{self.base_url.rstrip('/')}/v1/auth/session/refresh"
185
185
  with httpx.Client(timeout=30) as client:
186
- # Best-effort: send the (possibly expired) access token for anomaly tracking.
187
- # The backend refresh endpoint must not *require* it.
186
+ # Send the current access token alongside the refresh token.
187
+ # In multi-process setups callers must ensure this wrapper has been
188
+ # synchronized with the latest on-disk access token before refresh.
188
189
  headers: dict[str, str] = {}
189
190
  if self.access_token:
190
191
  headers["Authorization"] = f"Bearer {self.access_token}"
@@ -175,12 +175,19 @@ class _TokensLock:
175
175
  self.fp = None
176
176
 
177
177
 
178
- async def _refresh_tokens(http_base_url: str, refresh_token: str) -> dict[str, Any]:
178
+ async def _refresh_tokens(
179
+ http_base_url: str, refresh_token: str, access_token: str | None = None
180
+ ) -> dict[str, Any]:
179
181
  """Refresh tokens via the backend and return tokens.json-compatible dict."""
180
182
  from datetime import datetime, timedelta
181
183
  url = f"{http_base_url.rstrip('/')}/v1/auth/session/refresh"
184
+ headers: dict[str, str] = {}
185
+ if access_token:
186
+ headers["Authorization"] = f"Bearer {access_token}"
182
187
  async with httpx.AsyncClient(timeout=30) as client:
183
- resp = await client.post(url, params={"refresh_token": refresh_token})
188
+ resp = await client.post(
189
+ url, params={"refresh_token": refresh_token}, headers=headers
190
+ )
184
191
  if resp.status_code != 200:
185
192
  raise Exception(f"Token refresh failed (HTTP {resp.status_code}): {resp.text[:200]}")
186
193
  body = resp.json()
@@ -206,7 +213,6 @@ async def _refresh_tokens(http_base_url: str, refresh_token: str) -> dict[str, A
206
213
  out["expires_at"] = expires_at
207
214
  return out
208
215
 
209
-
210
216
  async def _get_valid_access_token(http_base_url: str, fallback_token: str) -> str:
211
217
  """Return a non-expired access token, preferring ~/.kiwi/tokens.json when available.
212
218
 
@@ -235,7 +241,9 @@ async def _get_valid_access_token(http_base_url: str, fallback_token: str) -> st
235
241
  return access2
236
242
  # Still expired -> attempt refresh. If refresh fails, fall back to the best token we have.
237
243
  try:
238
- new_data = await _refresh_tokens(http_base_url, refresh2)
244
+ new_data = await _refresh_tokens(
245
+ http_base_url, str(refresh2), str(access2) if access2 else None
246
+ )
239
247
  _atomic_write_tokens(TOKENS_PATH, new_data)
240
248
  return str(new_data.get("access_token"))
241
249
  except Exception:
@@ -253,6 +261,23 @@ async def _force_refresh_access_token(http_base_url: str) -> dict[str, Any] | No
253
261
  data = _load_tokens_file(TOKENS_PATH) or {}
254
262
  with _TokensLock():
255
263
  data2 = _load_tokens_file(TOKENS_PATH) or data or {}
264
+ access_token = data2.get("access_token")
265
+ expires_at = _parse_expires_at(data2.get("expires_at"))
266
+ if access_token and not _token_is_expired(access_token=str(access_token), expires_at=expires_at):
267
+ return data2
268
+ refresh_token = data2.get("refresh_token")
269
+ if not refresh_token:
270
+ return None
271
+ try:
272
+ new_data = await _refresh_tokens(
273
+ http_base_url, str(refresh_token), str(access_token) if access_token else None
274
+ )
275
+ _atomic_write_tokens(TOKENS_PATH, new_data)
276
+ return new_data
277
+ except Exception:
278
+ # If refresh failed, keep existing auth state and let the caller decide.
279
+ return None
280
+ data2 = _load_tokens_file(TOKENS_PATH) or data or {}
256
281
  refresh_token = data2.get("refresh_token")
257
282
  if not refresh_token:
258
283
  return None
@@ -167,6 +167,7 @@ class AutobotsTUI(App):
167
167
  self.pending_runtime_id: str | None = None
168
168
 
169
169
  self._kiwi_token = None # Set after auth for kiwi CLI session
170
+ self._token_refresh_timer = None # Periodic token refresh handle
170
171
  # self._kiwi_pid = None # PID of the runtime process
171
172
  # self._log_fp = None # File pointer for tailing runtime log
172
173
  self._kiwi_log_lines = collections.deque(maxlen=self._KIWI_LOG_BUFFER_SIZE)
@@ -179,11 +180,14 @@ class AutobotsTUI(App):
179
180
  with self.token_manager.file_lock():
180
181
  tokens = self.token_manager.load_tokens()
181
182
  if tokens:
182
- # Check if token needs refresh
183
183
  if tokens.is_expired():
184
184
  logger.info("Access token expired, attempting refresh")
185
- # Try to refresh token
186
- temp_client = AutobotsClientWrapper(base_url=self.config.backend_url)
185
+ # Try to refresh token using the latest access token loaded
186
+ # from disk, not stale in-memory client state.
187
+ temp_client = AutobotsClientWrapper(
188
+ base_url=self.config.backend_url,
189
+ access_token=tokens.access_token,
190
+ )
187
191
  success, new_tokens, message = temp_client.refresh_token(tokens.refresh_token)
188
192
 
189
193
  if success and new_tokens:
@@ -578,16 +582,19 @@ class AutobotsTUI(App):
578
582
  # self._kiwi_pid = None
579
583
  # self._close_log_tail()
580
584
  # self._kiwi_log_lines.append("--- Runtime disconnected ---")
581
-
582
- # ------------------------------------------------------------------
583
- # Periodic token refresh
584
- # ------------------------------------------------------------------
585
-
586
585
  def _start_token_refresh(self) -> None:
587
586
  """Schedule periodic token refresh (every `refresh_interval` minutes)."""
588
587
  interval_min = self.config.refresh_interval # default 5
589
588
  logger.info(f"Token refresh scheduled every {interval_min} minutes")
590
- self.set_interval(interval_min * 60, self._refresh_token_if_needed)
589
+ existing_timer = getattr(self, "_token_refresh_timer", None)
590
+ if existing_timer is not None:
591
+ try:
592
+ existing_timer.stop()
593
+ except Exception:
594
+ pass
595
+ self._token_refresh_timer = self.set_interval(
596
+ interval_min * 60, self._refresh_token_if_needed
597
+ )
591
598
  # Write current tokens to shared files so runtime can read them
592
599
  # if self._kiwi_token:
593
600
  # runtime_manager.save_token(self._kiwi_token)
@@ -596,10 +603,11 @@ class AutobotsTUI(App):
596
603
  # runtime_manager.save_refresh_token(tokens.refresh_token)
597
604
 
598
605
  def _refresh_token_if_needed(self, force: bool = False) -> bool:
599
- """Check token expiry and refresh if needed.
606
+ """Synchronize auth state from disk and refresh only when needed.
600
607
 
601
608
  Args:
602
- force: If True, attempt refresh even if the token doesn't look expired.
609
+ force: If True, re-check disk state immediately and refresh only if
610
+ the shared on-disk access token is still expired.
603
611
  """
604
612
  with self.token_manager.file_lock():
605
613
  tokens = self.token_manager.load_tokens()
@@ -607,14 +615,21 @@ class AutobotsTUI(App):
607
615
  logger.debug("No tokens to refresh")
608
616
  return False
609
617
 
610
- if (not force) and (not tokens.is_expired()):
611
- logger.debug("Token still valid, skipping refresh")
618
+ if not tokens.is_expired():
619
+ logger.debug("Token on disk is valid; synchronizing in-memory auth state")
620
+ if getattr(tokens, "access_token", None):
621
+ self.autobots_client.update_token(tokens.access_token)
622
+ self._kiwi_token = tokens.access_token
612
623
  return True
613
624
 
614
625
  if not getattr(tokens, "refresh_token", None):
615
626
  logger.warning("No refresh token available; cannot refresh access token")
616
627
  return False
617
628
 
629
+ if getattr(tokens, "access_token", None):
630
+ self.autobots_client.update_token(tokens.access_token)
631
+ self._kiwi_token = tokens.access_token
632
+
618
633
  logger.info("Refreshing access token...")
619
634
  success, new_tokens, message = self.autobots_client.refresh_token(tokens.refresh_token)
620
635
 
@@ -652,26 +667,6 @@ class AutobotsTUI(App):
652
667
  # if self._kiwi_pid:
653
668
  # self._stop_runtime()
654
669
  # logger.info("Runtime stopped for restart")
655
- # self._start_kiwi_session()
656
- # if self._kiwi_pid:
657
- # logger.info("Runtime restarted by user")
658
- # self.notify("Runtime restarted", severity="information")
659
- # else:
660
- # self.notify("Failed to restart runtime", severity="error")
661
-
662
- # def action_runtime_logs(self) -> None:
663
- # """Show the runtime log stream."""
664
- # self.push_screen("runtime_logs")
665
-
666
- def action_toggle_dark(self) -> None:
667
- """Toggle dark mode."""
668
- self.dark = not self.dark
669
- theme = "dark" if self.dark else "light"
670
- # Runtime-only: do not persist theme to disk.
671
- self.config.theme = theme
672
- logger.info(f"Theme changed to {theme}")
673
- self.notify(f"Theme: {theme}", severity="information")
674
-
675
670
  def action_show_logs(self) -> None:
676
671
  """Global keyboard shortcut to open CLI logs (if dashboard is active)."""
677
672
  try:
@@ -689,8 +684,17 @@ class AutobotsTUI(App):
689
684
  # self._close_log_tail()
690
685
  # self._kiwi_pid = None
691
686
 
687
+ timer = getattr(self, "_token_refresh_timer", None)
688
+ if timer is not None:
689
+ try:
690
+ timer.stop()
691
+ except Exception:
692
+ pass
693
+ self._token_refresh_timer = None
694
+
692
695
  # Clear tokens
693
696
  self.token_manager.clear_tokens()
697
+ self._kiwi_token = None
694
698
 
695
699
  # Reset client to unauthenticated
696
700
  self.autobots_client = AutobotsClientWrapper(
@@ -766,6 +770,43 @@ class AutobotsTUI(App):
766
770
  def action_quit(self) -> None:
767
771
  """Quit the application."""
768
772
  self.request_quit()
773
+ try:
774
+ all_rt = list_known_runtimes()
775
+ except Exception:
776
+ all_rt = []
777
+
778
+ alive = [r for r in all_rt if r.get("alive") and r.get("pid")]
779
+ if not alive:
780
+ self._final_exit()
781
+ return
782
+
783
+ rows: list[RuntimeRow] = []
784
+ for r in alive:
785
+ try:
786
+ meta = r.get("meta") or {}
787
+ name = None
788
+ if isinstance(meta, dict):
789
+ name = meta.get("run_name")
790
+ rows.append(
791
+ RuntimeRow(
792
+ kind=str(r.get("kind")),
793
+ runtime_id=str(r.get("id")),
794
+ pid=int(r.get("pid")),
795
+ alive=True,
796
+ log_path=str(r.get("log_path")),
797
+ name=name if isinstance(name, str) else None,
798
+ kill=False,
799
+ )
800
+ )
801
+ except Exception:
802
+ continue
803
+
804
+ # Show interactive prompt; the callback will exit.
805
+ self.push_screen(RuntimeCleanupScreen(rows), callback=self._on_runtime_cleanup_done)
806
+
807
+ def action_quit(self) -> None:
808
+ """Quit the application."""
809
+ self.request_quit()
769
810
 
770
811
 
771
812
  def _run_tui(runtime_args: RuntimeConnectArgs | None = None):
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.36"
400
+ version = "0.0.37"
401
401
  source = { editable = "." }
402
402
  dependencies = [
403
403
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes