kiwi-code 0.0.449__tar.gz → 0.0.501__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 (117) hide show
  1. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/PKG-INFO +2 -2
  2. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/pyproject.toml +2 -2
  3. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/cli.py +11 -0
  4. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/client.py +5 -0
  5. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/commands.py +367 -1
  6. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/sessions.py +13 -0
  7. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/term_dashboard.py +585 -55
  8. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/term_dashboard_helpers.py +119 -7
  9. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/slash_commands.py +5 -0
  10. kiwi_code-0.0.501/tests/test_run_steering.py +535 -0
  11. kiwi_code-0.0.501/tests/test_runtime_imports_on_windows.py +39 -0
  12. kiwi_code-0.0.501/tests/test_stats_command.py +210 -0
  13. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/uv.lock +5 -5
  14. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/.github/workflows/publish.yml +0 -0
  15. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/.github/workflows/test.yml +0 -0
  16. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/.gitignore +0 -0
  17. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/.python-version +0 -0
  18. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/Makefile +0 -0
  19. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/README.md +0 -0
  20. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/docs/runtime_audit.html +0 -0
  21. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/docs/social_login_e2e.md +0 -0
  22. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/scripts/set_edition.py +0 -0
  23. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/scripts/ws_auth_check.py +0 -0
  24. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/common/__init__.py +0 -0
  25. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/common/banner.py +0 -0
  26. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/__init__.py +0 -0
  27. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/_edition.py +0 -0
  28. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/auth.py +0 -0
  29. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/autocode.py +0 -0
  30. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/checkpoints.py +0 -0
  31. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/edition.py +0 -0
  32. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/logger.py +0 -0
  33. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/models.py +0 -0
  34. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/oauth.py +0 -0
  35. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/runtime_manager.py +0 -0
  36. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/server.py +0 -0
  37. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_cli/terminal_mode.py +0 -0
  38. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/__init__.py +0 -0
  39. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/__main__.py +0 -0
  40. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/auth.py +0 -0
  41. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/checkpoints.py +0 -0
  42. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/cli.py +0 -0
  43. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/commands.py +0 -0
  44. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/constants.py +0 -0
  45. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/__init__.py +0 -0
  46. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/batch.py +0 -0
  47. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/codelens.py +0 -0
  48. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/context.py +0 -0
  49. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/handler.py +0 -0
  50. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/paths.py +0 -0
  51. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/read.py +0 -0
  52. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/search.py +0 -0
  53. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/text.py +0 -0
  54. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/upload.py +0 -0
  55. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/fs/write.py +0 -0
  56. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/handlers.py +0 -0
  57. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/link.py +0 -0
  58. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/main.py +0 -0
  59. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/restricted.py +0 -0
  60. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/state.py +0 -0
  61. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/tui_bridge.py +0 -0
  62. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/ui.py +0 -0
  63. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_runtime/uploads.py +0 -0
  64. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/__init__.py +0 -0
  65. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/inline_file_picker.py +0 -0
  66. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/main.py +0 -0
  67. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/random_words.py +0 -0
  68. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/role_mode.py +0 -0
  69. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/runtime_agent.py +0 -0
  70. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/__init__.py +0 -0
  71. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/attach_content.py +0 -0
  72. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/command_result.py +0 -0
  73. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/detach_files.py +0 -0
  74. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/file_browser.py +0 -0
  75. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/help.py +0 -0
  76. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/id_picker.py +0 -0
  77. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/login.py +0 -0
  78. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  79. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  80. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/slash_picker.py +0 -0
  81. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/term_dashboard_css.py +0 -0
  82. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
  83. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/status_words.py +0 -0
  84. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/term_app.py +0 -0
  85. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/ui_settings.py +0 -0
  86. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/widgets.py +0 -0
  87. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/src/kiwi_tui/worktrees.py +0 -0
  88. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/test_hello.py +0 -0
  89. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/__init__.py +0 -0
  90. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/conftest.py +0 -0
  91. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_account_server.py +0 -0
  92. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_autocode_actions.py +0 -0
  93. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_batch_fs_tool.py +0 -0
  94. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_checkpoints.py +0 -0
  95. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_cli_help.py +0 -0
  96. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_copy_path_fs_tool.py +0 -0
  97. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_fs_edit_fidelity.py +0 -0
  98. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_imports.py +0 -0
  99. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_login_social.py +0 -0
  100. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_oauth.py +0 -0
  101. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_read_file_streaming.py +0 -0
  102. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_reexec_kiwi.py +0 -0
  103. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_role_mode_injection.py +0 -0
  104. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_runtime_cleanup_per_account.py +0 -0
  105. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_runtime_connection.py +0 -0
  106. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_runtime_fixes.py +0 -0
  107. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_runtime_log_trimming.py +0 -0
  108. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_runtime_social_login.py +0 -0
  109. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_search_in_files_fs_tool.py +0 -0
  110. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_slash_commands.py +0 -0
  111. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_term_dashboard_ui.py +0 -0
  112. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_terminal_mode.py +0 -0
  113. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_tokens.py +0 -0
  114. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_tui_interactive_runtime.py +0 -0
  115. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_tui_palette.py +0 -0
  116. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_unified_diff.py +0 -0
  117. {kiwi_code-0.0.449 → kiwi_code-0.0.501}/tests/test_worktrees.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kiwi-code
3
- Version: 0.0.449
3
+ Version: 0.0.501
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://kiwicode.ai
6
6
  Author-email: Anurag <anurag@meetkiwi.co>
@@ -19,7 +19,7 @@ Requires-Dist: httpx>=0.25.0
19
19
  Requires-Dist: loguru>=0.7.3
20
20
  Requires-Dist: psutil>=5.9.0
21
21
  Requires-Dist: pydantic>=2.12.5
22
- Requires-Dist: pykiwiai==0.0.13
22
+ Requires-Dist: pykiwiai==0.0.14
23
23
  Requires-Dist: setproctitle>=1.3.0
24
24
  Requires-Dist: textual-dev>=1.8.0
25
25
  Requires-Dist: textual>=8.1.1
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.449"
3
+ version = "0.0.501"
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"
7
7
  dependencies = [
8
- "pykiwiai==0.0.13",
8
+ "pykiwiai==0.0.14",
9
9
  "loguru>=0.7.3",
10
10
  "pydantic>=2.12.5",
11
11
  "textual>=8.1.1",
@@ -206,6 +206,17 @@ def runs_get_cmd(id: str = typer.Argument(help="Action run ID")):
206
206
 
207
207
  # -- top-level --------------------------------------------------------------
208
208
 
209
+ @app.command("stats")
210
+ def stats_cmd(
211
+ range: str = typer.Option(commands.DEFAULT_STATS_RANGE, "--range", help="7d, 30d, 90d or 1y"),
212
+ ):
213
+ """Show run statistics: runs, tokens, spend."""
214
+ if range not in commands.STATS_RANGES:
215
+ _print([f"Invalid range: {range}. Valid: {', '.join(commands.STATS_RANGES)}"])
216
+ raise typer.Exit(2)
217
+ _print(_run_with_refresh_retry(lambda c: commands.stats(c, range_key=range)))
218
+
219
+
209
220
  @app.command()
210
221
  def login(
211
222
  username: str = typer.Option(..., prompt=True, help="Email"),
@@ -547,6 +547,11 @@ class AutobotsClientWrapper:
547
547
 
548
548
  result_dict["id"] = run_id
549
549
 
550
+ # Prompts waiting behind the current turn: with any left, a finished turn
551
+ # is not the end of the run.
552
+ queued_inputs = getattr(getattr(result, "queued_inputs", None), "inputs", None)
553
+ result_dict["queued"] = len(queued_inputs) if isinstance(queued_inputs, list) else 0
554
+
550
555
  logger.info(f"Parsed result: status={result_dict.get('status')}, has_result={('result' in result_dict)}")
551
556
  logger.info(f"Result structure keys: {result_dict.keys()}")
552
557
  if "result" in result_dict and isinstance(result_dict["result"], dict):
@@ -4,19 +4,26 @@ Each command function takes an AuthenticatedClient and keyword args,
4
4
  and returns a list of strings (output lines).
5
5
  """
6
6
 
7
+ import re
8
+ import os
7
9
  from concurrent.futures import ThreadPoolExecutor
8
10
  from dataclasses import dataclass, field
9
- from typing import Iterator, Optional
11
+ from datetime import datetime, timedelta
12
+ from typing import Callable, Iterator, Optional
10
13
  from pykiwiai import AuthenticatedClient
11
14
  from pykiwiai.api.actions import (
12
15
  list_actions_v1_actions_get,
13
16
  get_action_v1_actions_id_get,
14
17
  cancel_action_run_v1_actions_action_result_id_cancel_post,
18
+ instruct_action_run_v1_actions_action_result_id_instruct_post,
15
19
  )
16
20
  from pykiwiai.api.action_results import (
21
+ get_action_queue_v1_action_results_action_result_id_queue_get,
22
+ update_action_queue_v1_action_results_action_result_id_queue_put,
17
23
  list_action_result_v1_action_results_get,
18
24
  get_action_result_v1_action_results_id_get,
19
25
  )
26
+ from pykiwiai.api.run_metrics import get_run_metrics_v1_run_metrics_get
20
27
  from pykiwiai.api.subagents import list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get
21
28
  from pykiwiai.api.token_usage import get_token_usage_for_run_v1_token_usage_run_id_get
22
29
 
@@ -208,6 +215,121 @@ def runs_cancel(client: AuthenticatedClient, *, id: str) -> list[str]:
208
215
  return cancel_lines(id, *cancel_run(client, id=id))
209
216
 
210
217
 
218
+ # steering a run that is in progress
219
+
220
+ def instruct_run(client: AuthenticatedClient, *, id: str, text: str, urls: Optional[list[str]] = None) -> int:
221
+ """Hand a run that is processing an instruction to read at its next step. Returns the
222
+ HTTP status: 202 when taken, 409 when the run is not processing."""
223
+ from pykiwiai.models.run_instruction import RunInstruction
224
+
225
+ resp = instruct_action_run_v1_actions_action_result_id_instruct_post.sync_detailed(
226
+ action_result_id=id, client=client, body=RunInstruction(text=text, urls=list(urls or [])),
227
+ )
228
+ return resp.status_code
229
+
230
+
231
+ # the queue: prompts waiting behind the turn in progress
232
+
233
+ def get_queue(client: AuthenticatedClient, *, id: str) -> tuple[int, list[str], int]:
234
+ """The prompts queued on a run, oldest first. Returns (HTTP status, prompts, the
235
+ queue's version, which a write has to quote)."""
236
+ status, blocks, version = _read_queue(client, id)
237
+ return status, [_block_text(block) for block in blocks], version
238
+
239
+
240
+ def _block_text(block: object) -> str:
241
+ return str(getattr(block, "text", None) or "")
242
+
243
+
244
+ def _read_queue(client: AuthenticatedClient, id: str) -> tuple[int, list, int]:
245
+ """The queue as the server stores it: (HTTP status, input blocks, version)."""
246
+ resp = get_action_queue_v1_action_results_action_result_id_queue_get.sync_detailed(
247
+ action_result_id=id, client=client,
248
+ )
249
+ queue = resp.parsed
250
+ inputs = getattr(queue, "inputs", None)
251
+ if resp.status_code != 200 or not isinstance(inputs, list):
252
+ return resp.status_code, [], 0
253
+ version = getattr(queue, "version_number", 0)
254
+ return 200, list(inputs), version if isinstance(version, int) else 0
255
+
256
+
257
+ @dataclass
258
+ class QueueRemoval:
259
+ status: int # HTTP status of the read or the write that decided it
260
+ removed: Optional[str] = None # the prompt taken out, when one was
261
+ waiting: int = 0 # how many are queued now
262
+ moved: bool = False # the queue is not what the caller was looking at
263
+
264
+
265
+ def remove_from_queue(
266
+ client: AuthenticatedClient, *, id: str, number: int, is_expected: Optional[Callable[[str], bool]] = None,
267
+ ) -> QueueRemoval:
268
+ """Take the prompt at `number` (1 is the next to run) out of a run's queue.
269
+
270
+ Positions shift whenever the server starts a queued prompt, so the caller can pass
271
+ `is_expected` to say what it believes is at that position; if something else is there
272
+ nothing is removed. The write quotes the version it read, so a change in between is
273
+ refused by the server rather than removing the wrong prompt."""
274
+ from pykiwiai.models.queued_inputs import QueuedInputs
275
+
276
+ status, blocks, version = _read_queue(client, id)
277
+ if status != 200:
278
+ return QueueRemoval(status)
279
+ if not 1 <= number <= len(blocks):
280
+ return QueueRemoval(200, waiting=len(blocks))
281
+ target = _block_text(blocks[number - 1])
282
+ if is_expected is not None and not is_expected(target):
283
+ return QueueRemoval(200, waiting=len(blocks), moved=True)
284
+ # The blocks go back as they came, so each keeps its author and attachments.
285
+ kept = blocks[: number - 1] + blocks[number:]
286
+ resp = update_action_queue_v1_action_results_action_result_id_queue_put.sync_detailed(
287
+ action_result_id=id, client=client, body=QueuedInputs(inputs=kept, version_number=version),
288
+ )
289
+ if resp.status_code == 200:
290
+ return QueueRemoval(200, removed=target, waiting=len(kept))
291
+ return QueueRemoval(resp.status_code, waiting=len(blocks), moved=resp.status_code == 409)
292
+
293
+
294
+ def clear_queue(client: AuthenticatedClient, *, id: str) -> tuple[int, int]:
295
+ """Drop every prompt queued on a run. Returns (HTTP status, how many were dropped).
296
+ The turn in progress is not touched. A prompt the server started, or one queued from
297
+ elsewhere, between the read and the write makes the write a 409; it is read again
298
+ and retried once."""
299
+ from pykiwiai.models.queued_inputs import QueuedInputs
300
+
301
+ status = 409
302
+ for _ in range(2):
303
+ status, prompts, version = get_queue(client, id=id)
304
+ if status != 200 or not prompts:
305
+ return status, 0
306
+ resp = update_action_queue_v1_action_results_action_result_id_queue_put.sync_detailed(
307
+ action_result_id=id, client=client,
308
+ body=QueuedInputs(inputs=[], version_number=version),
309
+ )
310
+ status = resp.status_code
311
+ if status == 200:
312
+ return 200, len(prompts)
313
+ if status == 404:
314
+ # The server keeps no queue for a run nothing was ever queued on.
315
+ return 200, 0
316
+ if status != 409:
317
+ break
318
+ return status, 0
319
+
320
+
321
+ def queue_lines(prompts: list[str], *, width: int = 100) -> list[str]:
322
+ """The queue as numbered lines, one prompt each, cut to fit."""
323
+ if not prompts:
324
+ return ["Nothing is queued."]
325
+ lines = [f"{len(prompts)} queued, in the order they will run:"]
326
+ for number, prompt in enumerate(prompts, 1):
327
+ text = " ".join(prompt.split())
328
+ room = max(10, width - 6)
329
+ lines.append(f" {number}. {text if len(text) <= room else text[: room - 1] + '…'}")
330
+ return lines
331
+
332
+
211
333
  # token usage
212
334
 
213
335
  @dataclass
@@ -395,6 +517,245 @@ def token_usage(client: AuthenticatedClient, *, run_id: str) -> list[str]:
395
517
  return token_usage_lines(get_token_usage(client, run_id=run_id))
396
518
 
397
519
 
520
+ # stats (the run metrics dashboard)
521
+
522
+ #: What `/stats` accepts -> the server's range, how it is named, and the bucket size.
523
+ STATS_RANGES: dict[str, tuple[str, str, str]] = {
524
+ "7d": ("last_7_days", "Last 7 days", "daily"),
525
+ "30d": ("last_30_days", "Last 30 days", "daily"),
526
+ "90d": ("last_90_days", "Last 90 days", "weekly"),
527
+ "1y": ("last_year", "Last year", "weekly"),
528
+ }
529
+ DEFAULT_STATS_RANGE = "7d"
530
+
531
+
532
+ @dataclass
533
+ class RunStats:
534
+ """The server's run metrics for one time range; `data` is its response as a dict."""
535
+ status_code: int
536
+ range_key: str
537
+ data: dict = field(default_factory=dict)
538
+
539
+ @property
540
+ def label(self) -> str:
541
+ return STATS_RANGES[self.range_key][1]
542
+
543
+ @property
544
+ def bucket_unit(self) -> str:
545
+ return "day" if STATS_RANGES[self.range_key][2] == "daily" else "week"
546
+
547
+ def runs_per_bucket(self) -> list[int]:
548
+ """Runs in each day or week of the range, oldest first. The server leaves out a
549
+ bucket with no runs, so the gaps are put back as zeros."""
550
+ step = timedelta(days=1 if self.bucket_unit == "day" else 7)
551
+ try:
552
+ buckets = sorted(
553
+ (datetime.fromisoformat(str(b["bucket"])).replace(tzinfo=None), int(b.get("runs") or 0))
554
+ for b in self.data.get("buckets") or []
555
+ )
556
+ start = datetime.fromisoformat(str(self.data["start"])).replace(tzinfo=None)
557
+ end = datetime.fromisoformat(str(self.data["end"])).replace(tzinfo=None)
558
+ except (KeyError, TypeError, ValueError):
559
+ return []
560
+ if not buckets:
561
+ return []
562
+ first = buckets[0][0]
563
+ before = max(0, int((first - start) / step))
564
+ series = [0] * (before + max(0, int((end - first) / step)) + 1)
565
+ for at, runs in buckets:
566
+ slot = before + round((at - first) / step)
567
+ if 0 <= slot < len(series):
568
+ series[slot] += runs
569
+ return series
570
+
571
+
572
+ def local_timezone_name() -> str:
573
+ """This machine's IANA timezone, so a day in the stats is a day here. UTC when it
574
+ cannot be told, which is also what the server falls back to for a name it rejects."""
575
+ name = os.environ.get("TZ", "").lstrip(":")
576
+ if not name:
577
+ try:
578
+ target = os.path.realpath("/etc/localtime")
579
+ if "zoneinfo/" in target:
580
+ name = target.split("zoneinfo/", 1)[1]
581
+ except OSError:
582
+ name = ""
583
+ try:
584
+ from zoneinfo import ZoneInfo
585
+
586
+ ZoneInfo(name)
587
+ except Exception:
588
+ return "UTC"
589
+ return name
590
+
591
+
592
+ def get_stats(client: AuthenticatedClient, *, range_key: str = DEFAULT_STATS_RANGE) -> RunStats:
593
+ """Fetch the run metrics dashboard for a time range."""
594
+ time_range, _, granularity = STATS_RANGES[range_key]
595
+ resp = get_run_metrics_v1_run_metrics_get.sync_detailed(
596
+ client=client, time_range=time_range, granularity=granularity, timezone=local_timezone_name(),
597
+ )
598
+ if resp.status_code != 200 or resp.parsed is None:
599
+ return RunStats(status_code=resp.status_code, range_key=range_key)
600
+ return RunStats(status_code=200, range_key=range_key, data=resp.parsed.to_dict())
601
+
602
+
603
+ def fmt_count(n) -> str:
604
+ """A count short enough for a table: 99.8M, 52.6K, 842."""
605
+ n = n or 0
606
+ for limit, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
607
+ if abs(n) >= limit:
608
+ return f"{n / limit:.1f}{suffix}"
609
+ return f"{n:,.0f}"
610
+
611
+
612
+ def fmt_money(v) -> str:
613
+ if v is None:
614
+ return "\u2013"
615
+ if 0 < v < 0.01:
616
+ return "<$0.01"
617
+ return f"${v:,.2f}"
618
+
619
+
620
+ def fmt_spend(totals: dict) -> str:
621
+ """Everything a group spent. When some of its runs are unpriced the server gives no
622
+ total, so the known part is shown with a `+` rather than passed off as the whole."""
623
+ total = totals.get("total_cost_usd")
624
+ if total is not None:
625
+ return fmt_money(total)
626
+ known = (totals.get("llm_cost_usd") or 0) + (totals.get("media_cost_usd") or 0)
627
+ return f"{fmt_money(known)}+" if known else "\u2013"
628
+
629
+
630
+ def fmt_duration(seconds) -> str:
631
+ if seconds is None:
632
+ return "\u2013"
633
+ seconds = int(round(seconds))
634
+ if seconds < 60:
635
+ return f"{seconds}s"
636
+ minutes, seconds = divmod(seconds, 60)
637
+ if minutes < 60:
638
+ return f"{minutes}m {seconds:02d}s"
639
+ hours, minutes = divmod(minutes, 60)
640
+ return f"{hours}h {minutes:02d}m"
641
+
642
+
643
+ def fmt_pct(v) -> str:
644
+ return "\u2013" if v is None else f"{v:.0%}"
645
+
646
+
647
+ def stats_changes(stats: RunStats) -> list[tuple[str, float]]:
648
+ """How runs, tokens and spend moved against the period before, as fractions. Empty
649
+ when that period had no runs, and a figure is left out when it had nothing to compare."""
650
+ now, before = stats.data.get("totals") or {}, stats.data.get("previous") or {}
651
+ if not before.get("runs"):
652
+ return []
653
+ changes = []
654
+ for label, key in (("runs", "runs"), ("tokens", "tokens_total"), ("spend", "total_cost_usd")):
655
+ old, new = before.get(key), now.get(key)
656
+ if old and new is not None:
657
+ changes.append((label, (new - old) / old))
658
+ return changes
659
+
660
+
661
+ def top_subjects(stats: RunStats) -> list[dict]:
662
+ """The busiest workflows first. The server ranks them by estimated hours saved, a
663
+ column this view does not show, so its order would look arbitrary here."""
664
+ return sorted(stats.data.get("top_subjects") or [], key=lambda s: -(s.get("runs") or 0))
665
+
666
+
667
+ def subject_name(subject: dict) -> str:
668
+ """A workflow's name; one the server could not name (deleted, say) keeps a short id."""
669
+ return subject.get("name") or f"(unnamed {str(subject.get('subject_id') or '')[:8]})"
670
+
671
+
672
+ TRIGGER_LABELS = {"api": "API", "batch": "Batch", "scheduled": "Scheduled", "webhook": "Webhook"}
673
+
674
+
675
+ def trigger_label(trigger: Optional[str]) -> str:
676
+ """A run's trigger as the web dashboard names it. Runs the server could not attribute
677
+ to a trigger come back as `unknown`."""
678
+ key = (trigger or "unknown").lower()
679
+ return TRIGGER_LABELS.get(key) or " ".join(w.capitalize() for w in re.split(r"[_\s-]+", key) if w)
680
+
681
+
682
+ def trigger_rows(stats: RunStats) -> list[tuple[str, str, str, str, str]]:
683
+ """What started the runs, largest share first: label, runs, share, tokens, spend. A run
684
+ spawned by another run is its own row, so what subagents cost is read off directly."""
685
+ rows = sorted(stats.data.get("by_trigger") or [], key=lambda x: x.get("runs") or 0, reverse=True)
686
+ total = sum(x.get("runs") or 0 for x in rows)
687
+ return [
688
+ (
689
+ trigger_label(x.get("trigger")), f"{x.get('runs') or 0:,}", fmt_pct((x.get("runs") or 0) / total),
690
+ fmt_count(x.get("tokens_total")), fmt_spend(x),
691
+ )
692
+ for x in rows if x.get("runs")
693
+ ]
694
+
695
+
696
+ def stats_lines(stats: RunStats) -> list[str]:
697
+ """The run metrics dashboard as plain output lines."""
698
+ if stats.status_code != 200:
699
+ return [f"Error: HTTP {stats.status_code}"]
700
+ data = stats.data
701
+ t = data.get("totals") or {}
702
+ if not t.get("runs"):
703
+ return [f"No runs in the {stats.label.lower()}."]
704
+
705
+ lines = [
706
+ f"{stats.label} ({data.get('scope', '')}, {data.get('timezone', '')})",
707
+ "",
708
+ f"Runs : {t['runs']:,} ({t.get('succeeded', 0):,} succeeded, {t.get('failed', 0):,} failed, "
709
+ f"{t.get('cancelled', 0):,} cancelled)",
710
+ f"Success rate : {fmt_pct(t.get('success_rate'))}",
711
+ f"Tokens : {fmt_count(t.get('tokens_total'))} (input {fmt_count(t.get('tokens_input'))}, "
712
+ f"cached {fmt_count(t.get('tokens_cached'))}, cache write {fmt_count(t.get('tokens_cache_write'))}, "
713
+ f"output {fmt_count(t.get('tokens_output'))})",
714
+ f"Cache hit : {fmt_pct(t.get('cache_hit_rate'))}",
715
+ f"Spend : {fmt_spend(t)} (LLM {fmt_money(t.get('llm_cost_usd'))}, media {fmt_money(t.get('media_cost_usd'))})",
716
+ f"Duration : {fmt_duration(t.get('duration_p50'))} median, {fmt_duration(t.get('duration_p95'))} slowest 5%",
717
+ ]
718
+ if t.get("hours_saved"):
719
+ lines.append(f"Hours saved : {t['hours_saved']:,.1f} (estimated)")
720
+ changes = stats_changes(stats)
721
+ if changes:
722
+ lines.append("Vs previous : " + ", ".join(f"{label} {change:+.0%}" for label, change in changes))
723
+
724
+ subjects = top_subjects(stats)
725
+ if subjects:
726
+ header = f"{'Workflow':<28} {'Runs':>6} {'Success':>8} {'Tokens':>9} {'Spend':>10}"
727
+ lines += ["", header, "-" * len(header)]
728
+ for s in subjects:
729
+ name = subject_name(s)
730
+ lines.append(
731
+ f"{name[:28]:<28} {s.get('runs', 0):>6,} {fmt_pct(s.get('success_rate')):>8} "
732
+ f"{fmt_count(s.get('tokens_total')):>9} {fmt_spend(s):>10}"
733
+ )
734
+ models = data.get("by_model") or []
735
+ if models:
736
+ header = f"{'Model':<28} {'Runs':>6} {'Tokens':>9} {'Spend':>10}"
737
+ lines += ["", header, "-" * len(header)]
738
+ for m in models:
739
+ lines.append(
740
+ f"{(m.get('model') or '(unknown)')[:28]:<28} {m.get('runs', 0):>6,} "
741
+ f"{fmt_count(m.get('tokens_total')):>9} {fmt_spend(m):>10}"
742
+ )
743
+ triggers = trigger_rows(stats)
744
+ if triggers:
745
+ header = f"{'Started by':<28} {'Runs':>6} {'Share':>8} {'Tokens':>9} {'Spend':>10}"
746
+ lines += ["", header, "-" * len(header)]
747
+ lines += [f"{label[:28]:<28} {runs:>6} {share:>8} {tokens:>9} {spend:>10}" for label, runs, share, tokens, spend in triggers]
748
+ errors = data.get("top_errors") or []
749
+ if errors:
750
+ lines += ["", "Failures : " + ", ".join(f"{x.get('error_class') or 'Unclassified'} {x.get('runs', 0):,}" for x in errors)]
751
+ return lines
752
+
753
+
754
+ def stats(client: AuthenticatedClient, *, range_key: str = DEFAULT_STATS_RANGE) -> list[str]:
755
+ """Run metrics for a time range. Returns output lines."""
756
+ return stats_lines(get_stats(client, range_key=range_key))
757
+
758
+
398
759
  # Command dispatcher — parses "/command args" from TUI input
399
760
 
400
761
  HELP_TEXT = """\
@@ -404,6 +765,11 @@ Session:
404
765
  /new Start new conversation
405
766
  /status Show current action & run
406
767
  /cancel [run_id] Cancel a running run (default: current run)
768
+ /queue <prompt> Run a prompt after the current turn finishes
769
+ /queue list Show the prompts waiting to run
770
+ /queue remove <n> Drop one waiting prompt, by its number in /queue list
771
+ /queue clear Drop every prompt waiting to run
772
+ /stats [7d|30d|90d|1y] Show run statistics: runs, tokens, spend (default: 7d)
407
773
  /usage [run_id] Show token usage for a run and its subagents (default: current run)
408
774
 
409
775
  Files:
@@ -398,6 +398,19 @@ if not IS_WINDOWS:
398
398
  except Exception:
399
399
  return False
400
400
 
401
+ else:
402
+
403
+ class PTYProcess: # type: ignore[no-redef]
404
+ """Stands in for the Unix PTY session on Windows, where there is no pty module.
405
+
406
+ The name has to exist so that modules importing it load on every platform; the
407
+ handlers only ever construct it when not on Windows, so this is never built.
408
+ """
409
+
410
+ def __init__(self, *args, **kwargs) -> None:
411
+ raise RuntimeError("PTY sessions are not available on Windows; use PipeProcess.")
412
+
413
+
401
414
  class PipeProcess:
402
415
  """Manages a persistent shell session using subprocess pipes (Windows-compatible)."""
403
416