kiwi-code 0.0.30__tar.gz → 0.0.31__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 (52) hide show
  1. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/cli.py +85 -11
  4. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/client.py +8 -1
  5. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/dashboard.py +251 -37
  6. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/uv.lock +1 -1
  7. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/.github/workflows/publish.yml +0 -0
  8. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/.github/workflows/test.yml +0 -0
  9. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/.gitignore +0 -0
  10. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/.python-version +0 -0
  11. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/CLAUDE.md +0 -0
  12. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/Makefile +0 -0
  13. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/README.md +0 -0
  14. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/__init__.py +0 -0
  15. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/auth.py +0 -0
  16. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/commands.py +0 -0
  17. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/logger.py +0 -0
  18. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/models.py +0 -0
  19. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/runtime_manager.py +0 -0
  20. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_cli/server.py +0 -0
  21. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_runtime/__init__.py +0 -0
  22. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_runtime/__main__.py +0 -0
  23. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_runtime/main.py +0 -0
  24. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  25. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  26. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/__init__.py +0 -0
  27. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/inline_file_picker.py +0 -0
  28. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/main.py +0 -0
  29. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/random_words.py +0 -0
  30. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/runtime_agent.py +0 -0
  31. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/__init__.py +0 -0
  32. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/attach_content.py +0 -0
  33. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/command_result.py +0 -0
  34. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/file_browser.py +0 -0
  35. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/help.py +0 -0
  36. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/id_picker.py +0 -0
  37. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/login.py +0 -0
  38. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  39. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  40. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/screens/slash_picker.py +0 -0
  41. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/slash_commands.py +0 -0
  42. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/status_words.py +0 -0
  43. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/src/kiwi_tui/widgets.py +0 -0
  44. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/test_hello.py +0 -0
  45. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/tests/__init__.py +0 -0
  46. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/tests/conftest.py +0 -0
  47. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/tests/test_cli_help.py +0 -0
  48. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/tests/test_imports.py +0 -0
  49. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/tests/test_reexec_kiwi.py +0 -0
  50. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/tests/test_runtime_log_trimming.py +0 -0
  51. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/tests/test_tokens.py +0 -0
  52. {kiwi_code-0.0.30 → kiwi_code-0.0.31}/tests/test_tui_headless.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.30
3
+ Version: 0.0.31
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.30"
3
+ version = "0.0.31"
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,6 +1,6 @@
1
1
  """Typer CLI for Autobots — thin wrapper over commands.py."""
2
2
 
3
- from typing import Optional
3
+ from typing import Optional, Callable
4
4
 
5
5
  from loguru import logger
6
6
  logger.remove() # Suppress loguru console output in CLI mode
@@ -55,18 +55,92 @@ def _backend_url() -> str:
55
55
  return AppConfig().backend_url
56
56
 
57
57
 
58
+ def _http_status_from_lines(lines: list[str]) -> int | None:
59
+ """Extract an HTTP status code from commands.py output lines.
60
+
61
+ commands.py returns errors in the form: ["Error: HTTP <code>"]
62
+ """
63
+ if not lines:
64
+ return None
65
+ if len(lines) != 1:
66
+ return None
67
+ s = lines[0].strip()
68
+ prefix = "Error: HTTP "
69
+ if not s.startswith(prefix):
70
+ return None
71
+ try:
72
+ return int(s[len(prefix):].strip())
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ def _force_refresh_tokens(tm: TokenManager) -> bool:
78
+ """Force refresh tokens once and persist them."""
79
+ from .client import AutobotsClientWrapper
80
+
81
+ with tm.file_lock():
82
+ tokens = tm.load_tokens()
83
+ if not tokens:
84
+ return False
85
+ refresh = getattr(tokens, "refresh_token", None)
86
+ if not refresh:
87
+ return False
88
+
89
+ wrapper = AutobotsClientWrapper(
90
+ base_url=_backend_url(),
91
+ access_token=getattr(tokens, "access_token", None),
92
+ )
93
+ ok, new_tokens, _msg = wrapper.refresh_token(str(refresh))
94
+ if ok and new_tokens:
95
+ tm.save_tokens(new_tokens)
96
+ return True
97
+ return False
98
+
99
+
58
100
  def _get_client() -> AuthenticatedClient:
101
+ """Get an authenticated OpenAPI client.
102
+
103
+ If the access token is expired but a refresh token exists, attempt a refresh
104
+ once. This improves CLI reliability for long-lived sessions.
105
+ """
59
106
  tm = TokenManager()
60
- if not tm.is_authenticated():
107
+ tokens = tm.load_tokens()
108
+ if not tokens:
109
+ typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
110
+ raise typer.Exit(code=1)
111
+
112
+ try:
113
+ if tokens.is_expired() and getattr(tokens, "refresh_token", None):
114
+ _force_refresh_tokens(tm)
115
+ tokens = tm.load_tokens() or tokens
116
+ except Exception:
117
+ pass
118
+
119
+ access = getattr(tokens, "access_token", None)
120
+ if not access:
61
121
  typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
62
122
  raise typer.Exit(code=1)
123
+
63
124
  return AuthenticatedClient(
64
125
  base_url=_backend_url(),
65
- token=tm.get_access_token(),
126
+ token=str(access),
66
127
  raise_on_unexpected_status=False,
67
128
  )
68
129
 
69
130
 
131
+ def _run_with_refresh_retry(run: Callable[[AuthenticatedClient], list[str]]) -> list[str]:
132
+ """Run a CLI command with one forced refresh retry on 401/403."""
133
+ tm = TokenManager()
134
+ lines = run(_get_client())
135
+ code = _http_status_from_lines(lines)
136
+ if code in (401, 403):
137
+ try:
138
+ if _force_refresh_tokens(tm):
139
+ lines = run(_get_client())
140
+ except Exception:
141
+ pass
142
+ return lines
143
+
70
144
  def _print(lines: list[str]) -> None:
71
145
  for line in lines:
72
146
  typer.echo(line)
@@ -81,12 +155,12 @@ def actions_list_cmd(
81
155
  offset: int = typer.Option(0, help="Offset"),
82
156
  ):
83
157
  """List actions."""
84
- _print(commands.actions_list(_get_client(), name=name, limit=limit, offset=offset))
158
+ _print(_run_with_refresh_retry(lambda c: commands.actions_list(c, name=name, limit=limit, offset=offset)))
85
159
 
86
160
  @actions_app.command("get")
87
161
  def actions_get_cmd(id: str = typer.Argument(help="Action ID")):
88
162
  """Get action details."""
89
- _print(commands.actions_get(_get_client(), id=id))
163
+ _print(_run_with_refresh_retry(lambda c: commands.actions_get(c, id=id)))
90
164
 
91
165
 
92
166
  # -- runs -------------------------------------------------------------------
@@ -100,12 +174,12 @@ def runs_list_cmd(
100
174
  offset: int = typer.Option(0, help="Offset"),
101
175
  ):
102
176
  """List action runs (results)."""
103
- _print(commands.runs_list(_get_client(), action_id=action_id, action_name=action_name, status=status, limit=limit, offset=offset))
177
+ _print(_run_with_refresh_retry(lambda c: commands.runs_list(c, action_id=action_id, action_name=action_name, status=status, limit=limit, offset=offset)))
104
178
 
105
179
  @action_runs_app.command("get")
106
180
  def runs_get_cmd(id: str = typer.Argument(help="Action run ID")):
107
181
  """Get action run details."""
108
- _print(commands.runs_get(_get_client(), id=id))
182
+ _print(_run_with_refresh_retry(lambda c: commands.runs_get(c, id=id)))
109
183
 
110
184
 
111
185
  # -- graphs -----------------------------------------------------------------
@@ -117,12 +191,12 @@ def graphs_list_cmd(
117
191
  offset: int = typer.Option(0, help="Offset"),
118
192
  ):
119
193
  """List action graphs."""
120
- _print(commands.graphs_list(_get_client(), name=name, limit=limit, offset=offset))
194
+ _print(_run_with_refresh_retry(lambda c: commands.graphs_list(c, name=name, limit=limit, offset=offset)))
121
195
 
122
196
  @graphs_app.command("get")
123
197
  def graphs_get_cmd(id: str = typer.Argument(help="Action graph ID")):
124
198
  """Get action graph details."""
125
- _print(commands.graphs_get(_get_client(), id=id))
199
+ _print(_run_with_refresh_retry(lambda c: commands.graphs_get(c, id=id)))
126
200
 
127
201
 
128
202
  # -- graph-runs -------------------------------------------------------------
@@ -136,12 +210,12 @@ def graph_runs_list_cmd(
136
210
  offset: int = typer.Option(0, help="Offset"),
137
211
  ):
138
212
  """List action graph runs (results)."""
139
- _print(commands.graph_runs_list(_get_client(), action_graph_id=graph_id, action_graph_name=graph_name, status=status, limit=limit, offset=offset))
213
+ _print(_run_with_refresh_retry(lambda c: commands.graph_runs_list(c, action_graph_id=graph_id, action_graph_name=graph_name, status=status, limit=limit, offset=offset)))
140
214
 
141
215
  @graph_runs_app.command("get")
142
216
  def graph_runs_get_cmd(id: str = typer.Argument(help="Graph run ID")):
143
217
  """Get action graph run details."""
144
- _print(commands.graph_runs_get(_get_client(), id=id))
218
+ _print(_run_with_refresh_retry(lambda c: commands.graph_runs_get(c, id=id)))
145
219
 
146
220
 
147
221
  # -- runtime ----------------------------------------------------------------
@@ -183,7 +183,14 @@ 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
- resp = client.post(url, params={"refresh_token": refresh_token})
186
+ # Best-effort: send the (possibly expired) access token for anomaly tracking.
187
+ # The backend refresh endpoint must not *require* it.
188
+ headers: dict[str, str] = {}
189
+ if self.access_token:
190
+ headers["Authorization"] = f"Bearer {self.access_token}"
191
+ resp = client.post(
192
+ url, params={"refresh_token": refresh_token}, headers=headers
193
+ )
187
194
 
188
195
  if resp.status_code != 200:
189
196
  detail = (resp.text or "").strip()
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import Callable
5
6
  from textual.app import ComposeResult
6
7
  from textual.binding import Binding
7
8
  from textual.screen import Screen
@@ -877,11 +878,12 @@ class DashboardScreen(Screen):
877
878
  show("Usage: /use <action_id>")
878
879
  return
879
880
  action_id = args[0]
880
- api_client = self._get_api_client_for_command(command)
881
- if not api_client:
882
- return
883
881
  from kiwi_cli.commands import actions_get
884
- lines = await asyncio.to_thread(lambda: actions_get(api_client, id=action_id))
882
+ lines = await self._run_lines_with_refresh_retry(
883
+ command, lambda c: actions_get(c, id=action_id)
884
+ )
885
+ if lines is None:
886
+ return
885
887
  if lines and lines[0].startswith("Error"):
886
888
  show(f"Action not found: {action_id}", is_error=True)
887
889
  return
@@ -898,11 +900,12 @@ class DashboardScreen(Screen):
898
900
  show("Usage: /continue <run_id>")
899
901
  return
900
902
  run_id = args[0]
901
- api_client = self._get_api_client_for_command(command)
902
- if not api_client:
903
- return
904
903
  from kiwi_cli.commands import runs_get
905
- lines = await asyncio.to_thread(lambda: runs_get(api_client, id=run_id))
904
+ lines = await self._run_lines_with_refresh_retry(
905
+ command, lambda c: runs_get(c, id=run_id)
906
+ )
907
+ if lines is None:
908
+ return
906
909
  if lines and lines[0].startswith("Error"):
907
910
  show(f"Run not found: {run_id}", is_error=True)
908
911
  return
@@ -1056,7 +1059,11 @@ class DashboardScreen(Screen):
1056
1059
 
1057
1060
  from kiwi_cli.commands import dispatch
1058
1061
  try:
1059
- lines = await asyncio.to_thread(lambda: dispatch(command, api_client))
1062
+ lines = await self._run_lines_with_refresh_retry(
1063
+ command, lambda c: dispatch(command, c)
1064
+ )
1065
+ if lines is None:
1066
+ return
1060
1067
  show("\n".join(lines))
1061
1068
  except Exception as e:
1062
1069
  show(f"Command error: {e}", is_error=True)
@@ -1070,9 +1077,26 @@ class DashboardScreen(Screen):
1070
1077
  """Fetch and render conversation history without blocking the UI."""
1071
1078
  if not hasattr(self.app, "autobots_client"):
1072
1079
  return
1073
- success, result, _msg = await asyncio.to_thread(
1080
+ success, result, msg = await asyncio.to_thread(
1074
1081
  lambda: self.app.autobots_client.get_action_result(run_id)
1075
1082
  )
1083
+ if (not success) and msg:
1084
+ code = None
1085
+ try:
1086
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
1087
+ if m:
1088
+ code = int(m.group(1))
1089
+ except Exception:
1090
+ code = None
1091
+ if code in (401, 403):
1092
+ try:
1093
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1094
+ except Exception:
1095
+ refreshed = False
1096
+ if refreshed:
1097
+ success, result, msg = await asyncio.to_thread(
1098
+ lambda: self.app.autobots_client.get_action_result(run_id)
1099
+ )
1076
1100
  if not success or not result:
1077
1101
  return
1078
1102
  self._render_conversation_history_from_action_result(result)
@@ -1093,6 +1117,22 @@ class DashboardScreen(Screen):
1093
1117
  offset=offset,
1094
1118
  )
1095
1119
  )
1120
+ if resp.status_code in (401, 403):
1121
+ # Reactive auth guard: force refresh and retry once.
1122
+ try:
1123
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1124
+ except Exception:
1125
+ refreshed = False
1126
+ if refreshed:
1127
+ api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1128
+ resp = await asyncio.to_thread(
1129
+ lambda: list_actions_v1_actions_get.sync_detailed(
1130
+ client=api_client,
1131
+ name=name if name else UNSET,
1132
+ limit=limit,
1133
+ offset=offset,
1134
+ )
1135
+ )
1096
1136
  if resp.status_code != 200 or not resp.parsed:
1097
1137
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1098
1138
  return
@@ -1147,6 +1187,23 @@ class DashboardScreen(Screen):
1147
1187
  offset=int(opts.get("offset", "0")),
1148
1188
  )
1149
1189
  )
1190
+ if resp.status_code in (401, 403):
1191
+ try:
1192
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1193
+ except Exception:
1194
+ refreshed = False
1195
+ if refreshed:
1196
+ api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1197
+ resp = await asyncio.to_thread(
1198
+ lambda: list_action_result_v1_action_results_get.sync_detailed(
1199
+ client=api_client,
1200
+ action_id=opts.get("action_id") or UNSET,
1201
+ action_name=opts.get("action_name") or UNSET,
1202
+ status=status_enum,
1203
+ limit=int(opts.get("limit", "50")),
1204
+ offset=int(opts.get("offset", "0")),
1205
+ )
1206
+ )
1150
1207
  if resp.status_code != 200 or not resp.parsed:
1151
1208
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1152
1209
  return
@@ -1188,6 +1245,21 @@ class DashboardScreen(Screen):
1188
1245
  offset=offset,
1189
1246
  )
1190
1247
  )
1248
+ if resp.status_code in (401, 403):
1249
+ try:
1250
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1251
+ except Exception:
1252
+ refreshed = False
1253
+ if refreshed:
1254
+ api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1255
+ resp = await asyncio.to_thread(
1256
+ lambda: list_action_graphs_v1_action_graphs_get.sync_detailed(
1257
+ client=api_client,
1258
+ name=name if name else UNSET,
1259
+ limit=limit,
1260
+ offset=offset,
1261
+ )
1262
+ )
1191
1263
  if resp.status_code != 200 or not resp.parsed:
1192
1264
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1193
1265
  return
@@ -1242,6 +1314,23 @@ class DashboardScreen(Screen):
1242
1314
  offset=int(opts.get("offset", "0")),
1243
1315
  )
1244
1316
  )
1317
+ if resp.status_code in (401, 403):
1318
+ try:
1319
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1320
+ except Exception:
1321
+ refreshed = False
1322
+ if refreshed:
1323
+ api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1324
+ resp = await asyncio.to_thread(
1325
+ lambda: list_action_graph_result_v1_action_graphs_results_get.sync_detailed(
1326
+ client=api_client,
1327
+ action_graph_id=opts.get("graph_id") or UNSET,
1328
+ action_graph_name=opts.get("graph_name") or UNSET,
1329
+ status=status_enum,
1330
+ limit=int(opts.get("limit", "50")),
1331
+ offset=int(opts.get("offset", "0")),
1332
+ )
1333
+ )
1245
1334
  if resp.status_code != 200 or not resp.parsed:
1246
1335
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1247
1336
  return
@@ -1302,6 +1391,47 @@ class DashboardScreen(Screen):
1302
1391
  return api_client
1303
1392
 
1304
1393
 
1394
+
1395
+ def _http_status_from_lines(self, lines: list[str]) -> int | None:
1396
+ """Extract status code from a commands.py-style error list."""
1397
+ if not lines:
1398
+ return None
1399
+ if len(lines) != 1:
1400
+ return None
1401
+ s = str(lines[0]).strip()
1402
+ m = re.match(r"^Error:\s*HTTP\s*(\d{3})\s*$", s)
1403
+ if not m:
1404
+ return None
1405
+ try:
1406
+ return int(m.group(1))
1407
+ except Exception:
1408
+ return None
1409
+
1410
+
1411
+ async def _run_lines_with_refresh_retry(
1412
+ self,
1413
+ title: str,
1414
+ fn: Callable[[object], list[str]],
1415
+ ) -> list[str] | None:
1416
+ """Run a slash-command lines function with one refresh+retry on 401/403."""
1417
+ api_client = self._get_api_client_for_command(title)
1418
+ if not api_client:
1419
+ return None
1420
+ lines = await asyncio.to_thread(lambda: fn(api_client))
1421
+ code = self._http_status_from_lines(lines)
1422
+ if code in (401, 403):
1423
+ refreshed = False
1424
+ try:
1425
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1426
+ except Exception:
1427
+ refreshed = False
1428
+ if refreshed:
1429
+ api_client = self._get_api_client_for_command(title)
1430
+ if not api_client:
1431
+ return lines
1432
+ lines = await asyncio.to_thread(lambda: fn(api_client))
1433
+ return lines
1434
+
1305
1435
  def _parse_command_opts(self, command: str, skip: int = 2) -> dict[str, str]:
1306
1436
  """Parse --key value pairs from a slash command string."""
1307
1437
  parts = command.strip().split()
@@ -1543,11 +1673,12 @@ class DashboardScreen(Screen):
1543
1673
 
1544
1674
  async def _on_action_picked_async(self, action_id: str) -> None:
1545
1675
  try:
1546
- api_client = self._get_api_client_for_command("/actions list")
1547
- if not api_client:
1548
- return
1549
1676
  from kiwi_cli.commands import actions_get
1550
- lines = await asyncio.to_thread(lambda: actions_get(api_client, id=action_id))
1677
+ lines = await self._run_lines_with_refresh_retry(
1678
+ "/actions list", lambda c: actions_get(c, id=action_id)
1679
+ )
1680
+ if lines is None:
1681
+ return
1551
1682
  if lines and lines[0].startswith("Error"):
1552
1683
  self._show_command_result("Action selection", f"Action not found: {action_id}", is_error=True)
1553
1684
  return
@@ -1578,11 +1709,12 @@ class DashboardScreen(Screen):
1578
1709
 
1579
1710
  async def _on_run_picked_async(self, run_id: str) -> None:
1580
1711
  try:
1581
- api_client = self._get_api_client_for_command("/runs list")
1582
- if not api_client:
1583
- return
1584
1712
  from kiwi_cli.commands import runs_get
1585
- lines = await asyncio.to_thread(lambda: runs_get(api_client, id=run_id))
1713
+ lines = await self._run_lines_with_refresh_retry(
1714
+ "/runs list", lambda c: runs_get(c, id=run_id)
1715
+ )
1716
+ if lines is None:
1717
+ return
1586
1718
  if lines and lines[0].startswith("Error"):
1587
1719
  self._show_command_result("Run selection", f"Run not found: {run_id}", is_error=True)
1588
1720
  return
@@ -1612,11 +1744,12 @@ class DashboardScreen(Screen):
1612
1744
 
1613
1745
  async def _on_graph_picked_async(self, graph_id: str) -> None:
1614
1746
  try:
1615
- api_client = self._get_api_client_for_command("/graphs list")
1616
- if not api_client:
1617
- return
1618
1747
  from kiwi_cli.commands import graphs_get
1619
- lines = await asyncio.to_thread(lambda: graphs_get(api_client, id=graph_id))
1748
+ lines = await self._run_lines_with_refresh_retry(
1749
+ "/graphs list", lambda c: graphs_get(c, id=graph_id)
1750
+ )
1751
+ if lines is None:
1752
+ return
1620
1753
  if lines and lines[0].startswith("Error"):
1621
1754
  self._show_command_result("Graph selection", f"Graph not found: {graph_id}", is_error=True)
1622
1755
  return
@@ -1642,11 +1775,12 @@ class DashboardScreen(Screen):
1642
1775
 
1643
1776
  async def _on_graph_run_picked_async(self, graph_run_id: str) -> None:
1644
1777
  try:
1645
- api_client = self._get_api_client_for_command("/graph-runs list")
1646
- if not api_client:
1647
- return
1648
1778
  from kiwi_cli.commands import graph_runs_get
1649
- lines = await asyncio.to_thread(lambda: graph_runs_get(api_client, id=graph_run_id))
1779
+ lines = await self._run_lines_with_refresh_retry(
1780
+ "/graph-runs list", lambda c: graph_runs_get(c, id=graph_run_id)
1781
+ )
1782
+ if lines is None:
1783
+ return
1650
1784
  if lines and lines[0].startswith("Error"):
1651
1785
  self._show_command_result("Graph run selection", f"Graph run not found: {graph_run_id}", is_error=True)
1652
1786
  return
@@ -2188,17 +2322,48 @@ class DashboardScreen(Screen):
2188
2322
  return
2189
2323
 
2190
2324
  if not success:
2191
- self.add_message(f"Error starting action: {message}", "error")
2192
- # Remove the placeholder row (no run was started).
2325
+ # Reactive auth guard: if the request failed with 401/403, force a token
2326
+ # refresh and retry once before surfacing the error.
2327
+ code = None
2193
2328
  try:
2194
- w = getattr(self, "_streaming_widget_ref", None)
2195
- if w:
2196
- w.remove()
2329
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
2330
+ if m:
2331
+ code = int(m.group(1))
2197
2332
  except Exception:
2198
- pass
2199
- self._streaming_widget_ref = None
2200
- self._set_streaming(False)
2201
- return
2333
+ code = None
2334
+
2335
+ if code in (401, 403):
2336
+ try:
2337
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
2338
+ except Exception:
2339
+ refreshed = False
2340
+ if refreshed:
2341
+ try:
2342
+ success, run_id, message = await loop.run_in_executor(
2343
+ None,
2344
+ lambda: client.run_action_async(
2345
+ self.current_action_id,
2346
+ user_input,
2347
+ action_result_id=self.current_run_id,
2348
+ urls=urls if urls else None,
2349
+ metadata=self._metadata if self._metadata else None,
2350
+ ),
2351
+ )
2352
+ except Exception:
2353
+ pass
2354
+
2355
+ if not success:
2356
+ self.add_message(f"Error starting action: {message}", "error")
2357
+ # Remove the placeholder row (no run was started).
2358
+ try:
2359
+ w = getattr(self, "_streaming_widget_ref", None)
2360
+ if w:
2361
+ w.remove()
2362
+ except Exception:
2363
+ pass
2364
+ self._streaming_widget_ref = None
2365
+ self._set_streaming(False)
2366
+ return
2202
2367
 
2203
2368
  # Check if this is continuing an existing conversation
2204
2369
  if self.current_run_id and run_id == self.current_run_id:
@@ -2312,17 +2477,51 @@ class DashboardScreen(Screen):
2312
2477
  if got_final_result:
2313
2478
  return True
2314
2479
 
2480
+ auth_failed_code: int | None = None
2481
+
2315
2482
  try:
2316
2483
  async with fetch_lock:
2317
2484
  if got_final_result:
2318
2485
  return True
2319
- success, final_result, _message = await asyncio.to_thread(
2486
+ success, final_result, message = await asyncio.to_thread(
2320
2487
  client.get_action_result, run_id
2321
2488
  )
2489
+
2490
+ if not success:
2491
+ code = None
2492
+ try:
2493
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
2494
+ if m:
2495
+ code = int(m.group(1))
2496
+ except Exception:
2497
+ code = None
2498
+
2499
+ if code in (401, 403):
2500
+ try:
2501
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
2502
+ except Exception:
2503
+ refreshed = False
2504
+ if refreshed:
2505
+ success, final_result, message = await asyncio.to_thread(
2506
+ client.get_action_result, run_id
2507
+ )
2508
+ if (not success) and (code in (401, 403)):
2509
+ auth_failed_code = code
2322
2510
  except Exception as e:
2323
2511
  logger.warning(f"Poll error: {e}")
2324
2512
  return False
2325
2513
 
2514
+
2515
+ if auth_failed_code in (401, 403):
2516
+ self.add_message(
2517
+ f"Authentication error while fetching result (HTTP {auth_failed_code}). Please /login again.",
2518
+ "error",
2519
+ )
2520
+ got_final_result = True
2521
+ # Stop spinner/blink immediately when we have a terminal state.
2522
+ self._set_streaming(False)
2523
+ return True
2524
+
2326
2525
  if not success or not final_result:
2327
2526
  return False
2328
2527
 
@@ -2613,7 +2812,22 @@ class DashboardScreen(Screen):
2613
2812
  """
2614
2813
  if not hasattr(self.app, "autobots_client"):
2615
2814
  return
2616
- success, result, _msg = self.app.autobots_client.get_action_result(run_id)
2815
+ success, result, msg = self.app.autobots_client.get_action_result(run_id)
2816
+ if (not success) and msg:
2817
+ code = None
2818
+ try:
2819
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
2820
+ if m:
2821
+ code = int(m.group(1))
2822
+ except Exception:
2823
+ code = None
2824
+ if code in (401, 403):
2825
+ try:
2826
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
2827
+ except Exception:
2828
+ refreshed = False
2829
+ if refreshed:
2830
+ success, result, msg = self.app.autobots_client.get_action_result(run_id)
2617
2831
  if not success or not result:
2618
2832
  return
2619
2833
  self._render_conversation_history_from_action_result(result)
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.30"
400
+ version = "0.0.31"
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