delos-cli 1.0.2__tar.gz → 1.0.4__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 (79) hide show
  1. delos_cli-1.0.4/.claude/settings.local.json +8 -0
  2. {delos_cli-1.0.2 → delos_cli-1.0.4}/.gitignore +10 -0
  3. {delos_cli-1.0.2 → delos_cli-1.0.4}/DELOS.md +2 -2
  4. {delos_cli-1.0.2 → delos_cli-1.0.4}/PKG-INFO +3 -3
  5. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/chat/app.py +20 -1
  6. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/chat/commands.py +34 -1
  7. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/chat/render.py +14 -0
  8. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/replay.py +3 -1
  9. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/config.py +101 -9
  10. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/oauth.py +29 -19
  11. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/token_manager.py +10 -7
  12. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/commands/prompts.py +6 -0
  13. delos_cli-1.0.4/delos_cli/context_report.py +176 -0
  14. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ctx.py +21 -1
  15. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/main.py +3 -3
  16. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/server.py +48 -0
  17. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/__init__.py +13 -0
  18. delos_cli-1.0.4/delos_cli/tools/ask_user_input.py +98 -0
  19. delos_cli-1.0.4/delos_cli/tools/edit_content.py +442 -0
  20. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/explore.py +7 -2
  21. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/run_shell.py +33 -5
  22. delos_cli-1.0.4/delos_cli/tools/verify.py +347 -0
  23. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/write_content.py +27 -43
  24. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/client.py +18 -2
  25. delos_cli-1.0.4/delos_cli/ui/banner.py +102 -0
  26. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/chat_picker.py +50 -22
  27. delos_cli-1.0.4/delos_cli/ui/completer.py +135 -0
  28. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/lexer.py +14 -1
  29. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/output.py +4 -1
  30. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/repl.py +344 -17
  31. delos_cli-1.0.4/delos_cli/ui/style.py +51 -0
  32. {delos_cli-1.0.2 → delos_cli-1.0.4}/pyproject.toml +8 -3
  33. delos_cli-1.0.2/delos_cli/apps/chat/commands.py.bak +0 -17
  34. delos_cli-1.0.2/delos_cli/tools/edit_content.py +0 -224
  35. delos_cli-1.0.2/delos_cli/ui/banner.py +0 -51
  36. delos_cli-1.0.2/delos_cli/ui/completer.py +0 -68
  37. delos_cli-1.0.2/delos_cli/ui/style.py +0 -24
  38. {delos_cli-1.0.2 → delos_cli-1.0.4}/README.md +0 -0
  39. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/__init__.py +0 -0
  40. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/__init__.py +0 -0
  41. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/session.py +0 -0
  42. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/tools.py +0 -0
  43. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/transport.py +0 -0
  44. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/turn.py +0 -0
  45. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/__init__.py +0 -0
  46. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/base.py +0 -0
  47. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/chat/__init__.py +0 -0
  48. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/scribe/__init__.py +0 -0
  49. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/scribe/app.py +0 -0
  50. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/scribe/commands.py +0 -0
  51. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/scribe/tools.py +0 -0
  52. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/__init__.py +0 -0
  53. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/mfa.py +0 -0
  54. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/commands/__init__.py +0 -0
  55. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/commands/base.py +0 -0
  56. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/commands/builtin.py +0 -0
  57. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/git_context.py +0 -0
  58. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/loop.py +0 -0
  59. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/project_context.py +0 -0
  60. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/__init__.py +0 -0
  61. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/app.py +0 -0
  62. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/confirm.py +0 -0
  63. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/protocol.py +0 -0
  64. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/rpc.py +0 -0
  65. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/state.py +0 -0
  66. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/glob_tool.py +0 -0
  67. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/grep.py +0 -0
  68. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/read.py +0 -0
  69. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/task.py +0 -0
  70. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/todo_write.py +0 -0
  71. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/__init__.py +0 -0
  72. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/chats.py +0 -0
  73. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/documents.py +0 -0
  74. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/integrations.py +0 -0
  75. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/models.py +0 -0
  76. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/__init__.py +0 -0
  77. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/document_picker.py +0 -0
  78. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/integrations_picker.py +0 -0
  79. {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/model_picker.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "disabledMcpjsonServers": [
3
+ "sentry",
4
+ "agent-analysis-localhost",
5
+ "agent-analysis-staging",
6
+ "agent-analysis-prod"
7
+ ]
8
+ }
@@ -96,6 +96,12 @@ yarn-error.log*
96
96
  !apps/vscode/.vscode/
97
97
  .zed
98
98
 
99
+ # versioned editor defaults (format-on-save) — keep personal settings in VS Code user settings
100
+ !/.vscode
101
+ /.vscode/*
102
+ !/.vscode/settings.json
103
+ !/.vscode/extensions.json
104
+
99
105
  # contentlayer
100
106
  .contentlayer/
101
107
 
@@ -138,6 +144,10 @@ apps/mobile/*.p12
138
144
  apps/mobile/*.key
139
145
  apps/mobile/.metro-health-check*
140
146
 
147
+ # Desktop App (Electron) — build output (node_modules/build already ignored above)
148
+ apps/desktop/dist/
149
+ apps/desktop/release/
150
+
141
151
 
142
152
  .gitnexus
143
153
 
@@ -136,8 +136,8 @@ Executed locally on the user's machine; results posted back via `POST /cli/agent
136
136
  |---|---|---|---|---|
137
137
  | `run_shell` | ✓ | ✓ | ✅ | Allowlist for safe commands; otherwise prompts via `ctx.confirm` |
138
138
  | `read` | ✓ | ✓ | ✅ | UTF-8 only, line-numbered slice; skips binaries |
139
- | `write_content` | ✓ | ✓ | ✅ | Write to **empty files only** (no clobber) |
140
- | `edit_content` | ✓ | ✓ | ✅ | Strict find/replace |
139
+ | `write_content` | ✓ | ✓ | ✅ | Creates the file (+ parent dirs) if missing; refuses to clobber non-empty files |
140
+ | `edit_content` | ✓ | ✓ | ✅ | Strict find/replace — single (`old_content`/`new_content`) or batched (`edits: [{old_content, new_content}, ...]`), all-or-nothing |
141
141
  | `grep` | ✓ | ✓ | ✅ | ripgrep wrapper (read-only) |
142
142
  | `glob` | ✓ | ✓ | ✅ | Glob pattern matching (read-only) |
143
143
  | `todo_write` | ✓ | ✓ (silent) | — | Replaces full plan in [TodoStore](cli/delos_cli/state.py); panel above input shows state |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: delos-cli
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Terminal REPL for the Delos agent — the Claude-Code-style CLI for Delos.
5
5
  Project-URL: Homepage, https://delos.so
6
6
  Project-URL: Repository, https://github.com/Delos-Intelligence/cosmos-saas
@@ -24,8 +24,8 @@ Requires-Dist: httpx>=0.27.0
24
24
  Requires-Dist: prompt-toolkit>=3.0.47
25
25
  Requires-Dist: questionary>=2.0.1
26
26
  Requires-Dist: rich>=13.8.0
27
- Requires-Dist: ripgrep>=15; sys_platform == 'darwin' and platform_machine == 'arm64'
28
- Requires-Dist: ripgrep>=15; sys_platform == 'linux' and platform_machine == 'x86_64'
27
+ Requires-Dist: ripgrep<15,>=14; sys_platform == 'darwin' and platform_machine == 'arm64'
28
+ Requires-Dist: ripgrep<15,>=14; sys_platform == 'linux' and platform_machine == 'x86_64'
29
29
  Requires-Dist: typer>=0.12.5
30
30
  Description-Content-Type: text/markdown
31
31
 
@@ -25,20 +25,26 @@ from delos_cli.apps.replay import replay_messages
25
25
  from delos_cli.git_context import collect_git_context
26
26
  from delos_cli.state import TodoStore
27
27
  from delos_cli.tools import (
28
+ handle_ask_user_input,
28
29
  handle_edit_content,
29
30
  handle_explore,
30
31
  handle_glob,
31
32
  handle_grep,
33
+ handle_lint_target,
32
34
  handle_read,
33
35
  handle_run_shell,
36
+ handle_run_tests,
34
37
  handle_todo_write,
35
38
  handle_write_content,
39
+ render_ask_user_input,
36
40
  render_edit_content,
37
41
  render_explore,
38
42
  render_glob,
39
43
  render_grep,
44
+ render_lint_target,
40
45
  render_read,
41
46
  render_run_shell,
47
+ render_run_tests,
42
48
  render_task,
43
49
  render_todo_write,
44
50
  render_write_content,
@@ -75,6 +81,8 @@ def default_tool_registry() -> ToolRegistry:
75
81
  reg.handlers["todo_write"] = handle_todo_write
76
82
  # Silent renderer — the permanent panel above the input shows the state.
77
83
  reg.renderers["todo_write"] = render_todo_write
84
+ reg.handlers["ask_user_input"] = handle_ask_user_input
85
+ reg.renderers["ask_user_input"] = render_ask_user_input
78
86
  # `task` is server-side (no handler needed); the renderer shows the
79
87
  # sub-agent's input/output lifecycle in the terminal.
80
88
  reg.renderers["task"] = render_task
@@ -82,6 +90,11 @@ def default_tool_registry() -> ToolRegistry:
82
90
  # only its report — see delos_cli.tools.explore.
83
91
  reg.handlers["explore"] = handle_explore
84
92
  reg.renderers["explore"] = render_explore
93
+ # Verification tools — runner/linter auto-detection, see delos_cli.tools.verify.
94
+ reg.handlers["run_tests"] = handle_run_tests
95
+ reg.renderers["run_tests"] = render_run_tests
96
+ reg.handlers["lint_target"] = handle_lint_target
97
+ reg.renderers["lint_target"] = render_lint_target
85
98
  return reg
86
99
 
87
100
 
@@ -151,7 +164,13 @@ class ChatApp(App):
151
164
 
152
165
  self._current_session = session
153
166
  try:
154
- async for event in session.send_turn(body, ctx):
167
+ # emit_tool_output=True so client tools that render a completion
168
+ # line (explore's ✓) get a synthetic tool-output-available — the
169
+ # backend never sends one for client-side tools. Without it the
170
+ # 🔎 explore line hangs with no closing ✓. Matches the serve.py
171
+ # (VSCode) path. Input-only renderers (read/grep/glob/run_shell)
172
+ # return None on the output event, so nothing double-renders.
173
+ async for event in session.send_turn(body, ctx, emit_tool_output=True):
155
174
  yield event
156
175
  finally:
157
176
  self._current_session = None
@@ -18,6 +18,7 @@ Two modes:
18
18
  from __future__ import annotations
19
19
 
20
20
  import contextlib
21
+ from pathlib import Path
21
22
  from typing import TYPE_CHECKING, Any
22
23
 
23
24
  from prompt_toolkit.application import get_app
@@ -25,6 +26,8 @@ from rich.text import Text
25
26
 
26
27
  from delos_cli.commands.base import CommandSpec, registry_from
27
28
  from delos_cli.commands.prompts import EXECUTE_PROMPT, INIT_PROMPT_NEW, INIT_PROMPT_UPDATE
29
+ from delos_cli.context_report import fetch_context_report, render_report
30
+ from delos_cli.git_context import collect_git_context
28
31
  from delos_cli.transport.chats import patch_chat_model
29
32
  from delos_cli.transport.client import TransportError
30
33
 
@@ -128,4 +131,34 @@ EXECUTE = CommandSpec(
128
131
  )
129
132
 
130
133
 
131
- CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL, INIT, EXECUTE])
134
+ # ---------------------------------------------------------------------------
135
+ # /context — token breakdown of the next turn's LLM payload
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ async def _handle_context(ctx: Ctx, args: str) -> None:
140
+ """Fetch the backend context-report for this chat and render it."""
141
+ _ = args
142
+ sink = _sink(ctx)
143
+ git_ctx = await collect_git_context(Path.cwd())
144
+ try:
145
+ report = await fetch_context_report(
146
+ ctx.http,
147
+ ctx.state.conv_id,
148
+ custom_instructions=ctx.custom_instructions or None,
149
+ git_context=git_ctx,
150
+ )
151
+ except TransportError as e:
152
+ sink.print(Text(f"context report failed: {e}", style="red"))
153
+ return
154
+ sink.print(render_report(report))
155
+
156
+
157
+ CONTEXT = CommandSpec(
158
+ name="/context",
159
+ summary="show what fills the agent's context window (token breakdown)",
160
+ handler=_handle_context,
161
+ )
162
+
163
+
164
+ CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL, INIT, EXECUTE, CONTEXT])
@@ -55,6 +55,10 @@ class V6Renderer:
55
55
  self._spinner = Spinner("dots", text=" thinking", style="dim")
56
56
  self._thinking = False
57
57
  self._anim_task: asyncio.Task[None] | None = None
58
+ # Inside a <think>…</think> fence — AgentV2 streams model reasoning
59
+ # inline between literal tag deltas. Reasoning is swallowed (the
60
+ # spinner keeps running) so it never lands in scrollback.
61
+ self._in_think = False
58
62
 
59
63
  def apply(self, event: dict[str, Any]) -> None:
60
64
  """Apply a single v6 event to the output buffer."""
@@ -76,6 +80,16 @@ class V6Renderer:
76
80
  delta = event.get("delta", "")
77
81
  if not delta:
78
82
  return
83
+ # Each <think> / </think> tag arrives as its own delta
84
+ # (agent_v2 injects them around reasoning), so plain equality
85
+ # checks are enough — no tag can be split across deltas.
86
+ if delta == "<think>":
87
+ self._in_think = True
88
+ return
89
+ if self._in_think:
90
+ if delta.startswith("</think>"):
91
+ self._in_think = False
92
+ return
79
93
  self._stop_thinking()
80
94
  self._assistant_text += delta
81
95
  self._output.update_live(Markdown(self._assistant_text))
@@ -20,6 +20,7 @@ from rich.markdown import Markdown
20
20
  from rich.text import Text
21
21
 
22
22
  from delos_cli.agent import default_renderer
23
+ from delos_cli.ui.style import user_echo
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from delos_cli.apps.base import App
@@ -49,7 +50,8 @@ def replay_messages(
49
50
  if role == "user":
50
51
  text = _extract_text(msg.get("content"))
51
52
  if text:
52
- output.print(Text(f"» {text}\n", style="bold cyan"))
53
+ output.print(user_echo(text))
54
+ output.print(Text(""))
53
55
  elif role == "assistant":
54
56
  text = _extract_text(msg.get("content"))
55
57
  if text:
@@ -22,7 +22,7 @@ import tempfile
22
22
  from contextlib import contextmanager
23
23
  from dataclasses import asdict, dataclass, field
24
24
  from pathlib import Path
25
- from typing import IO, TYPE_CHECKING, TypedDict
25
+ from typing import IO, TYPE_CHECKING, Any, TypedDict
26
26
 
27
27
  from delos_cli import _editable_source_dir
28
28
 
@@ -121,6 +121,23 @@ class Tokens:
121
121
  expires_at: int = 0 # unix timestamp
122
122
 
123
123
 
124
+ @dataclass
125
+ class Session:
126
+ """Signed-in identity + token pair for one region.
127
+
128
+ Refresh tokens are region-scoped (each region is a separate Supabase
129
+ project), so sending region A's refresh token to region B's auth server
130
+ yields ``invalid_grant`` and forces a re-login. Keeping one Session per
131
+ region lets ``--region`` / ``DELOS_REGION`` switches round-trip without
132
+ burning the other region's session.
133
+ """
134
+
135
+ org_uuid: str = ""
136
+ user_id: str = ""
137
+ client_id: str = ""
138
+ tokens: Tokens = field(default_factory=Tokens)
139
+
140
+
124
141
  @dataclass
125
142
  class Config:
126
143
  """Persisted CLI configuration: which region, who we're signed in as."""
@@ -130,6 +147,10 @@ class Config:
130
147
  user_id: str = ""
131
148
  client_id: str = "" # cached OAuth client_id from /api/oauth/register
132
149
  tokens: Tokens = field(default_factory=Tokens)
150
+ # Stashed sessions for non-active regions, keyed by region name. The
151
+ # active region's session lives in the flat fields above (kept flat for
152
+ # backward compatibility with older CLI builds reading the same file).
153
+ sessions: dict[str, Session] = field(default_factory=dict)
133
154
 
134
155
  @property
135
156
  def api_url(self) -> str:
@@ -175,6 +196,44 @@ class Config:
175
196
  return bool(self.tokens.access_token and self.org_uuid)
176
197
 
177
198
 
199
+ def _parse_tokens(tok: dict[str, Any]) -> Tokens:
200
+ """Build a ``Tokens`` from a raw JSON dict, tolerating missing fields."""
201
+ return Tokens(
202
+ access_token=tok.get("access_token", ""),
203
+ refresh_token=tok.get("refresh_token", ""),
204
+ expires_at=int(tok.get("expires_at", 0)),
205
+ )
206
+
207
+
208
+ def _current_session(cfg: Config) -> Session:
209
+ """Snapshot the active region's flat fields as a ``Session``."""
210
+ return Session(
211
+ org_uuid=cfg.org_uuid,
212
+ user_id=cfg.user_id,
213
+ client_id=cfg.client_id,
214
+ tokens=cfg.tokens,
215
+ )
216
+
217
+
218
+ def set_region(cfg: Config, region: str) -> None:
219
+ """Switch the active region, swapping sessions instead of leaking tokens.
220
+
221
+ Stashes the current region's session into ``cfg.sessions`` and adopts the
222
+ target region's stashed session (empty if never signed in there). Without
223
+ this swap, a region switch keeps the old region's refresh token and burns
224
+ it against the wrong auth server.
225
+ """
226
+ if region == cfg.region:
227
+ return
228
+ cfg.sessions[cfg.region] = _current_session(cfg)
229
+ cfg.region = region
230
+ adopted = cfg.sessions.get(region, Session())
231
+ cfg.org_uuid = adopted.org_uuid
232
+ cfg.user_id = adopted.user_id
233
+ cfg.client_id = adopted.client_id
234
+ cfg.tokens = adopted.tokens
235
+
236
+
178
237
  def load() -> Config:
179
238
  """Load config from disk, apply DELOS_REGION/DELOS_ENV env overrides."""
180
239
  cfg = Config()
@@ -185,12 +244,15 @@ def load() -> Config:
185
244
  cfg.org_uuid = data.get("org_uuid", cfg.org_uuid)
186
245
  cfg.user_id = data.get("user_id", cfg.user_id)
187
246
  cfg.client_id = data.get("client_id", cfg.client_id)
188
- tok = data.get("tokens") or {}
189
- cfg.tokens = Tokens(
190
- access_token=tok.get("access_token", ""),
191
- refresh_token=tok.get("refresh_token", ""),
192
- expires_at=int(tok.get("expires_at", 0)),
193
- )
247
+ cfg.tokens = _parse_tokens(data.get("tokens") or {})
248
+ for name, raw in (data.get("sessions") or {}).items():
249
+ if isinstance(raw, dict):
250
+ cfg.sessions[name] = Session(
251
+ org_uuid=raw.get("org_uuid", ""),
252
+ user_id=raw.get("user_id", ""),
253
+ client_id=raw.get("client_id", ""),
254
+ tokens=_parse_tokens(raw.get("tokens") or {}),
255
+ )
194
256
  except (json.JSONDecodeError, OSError, ValueError):
195
257
  pass
196
258
 
@@ -201,10 +263,10 @@ def load() -> Config:
201
263
  known = ", ".join(sorted(allowed))
202
264
  msg = f"Unknown region '{region_override}'. Known: {known}"
203
265
  raise ValueError(msg)
204
- cfg.region = region_override
266
+ set_region(cfg, region_override)
205
267
 
206
268
  if cfg.region not in available_regions():
207
- cfg.region = DEFAULT_REGION
269
+ set_region(cfg, DEFAULT_REGION)
208
270
 
209
271
  return cfg
210
272
 
@@ -218,6 +280,9 @@ def save(cfg: Config) -> Path:
218
280
  :func:`config_file_lock` around their read-modify-write.
219
281
  """
220
282
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
283
+ # Mirror the active region's session into the stash so a later region
284
+ # switch (or an older CLI build rewriting the file) can't lose it.
285
+ cfg.sessions[cfg.region] = _current_session(cfg)
221
286
  payload = json.dumps(asdict(cfg), indent=2)
222
287
  fd, tmp_path_str = tempfile.mkstemp(
223
288
  prefix=".config-",
@@ -239,6 +304,33 @@ def save(cfg: Config) -> Path:
239
304
  return CONFIG_PATH
240
305
 
241
306
 
307
+ def region_tokens(cfg: Config, region: str) -> Tokens:
308
+ """Tokens for ``region``: the flat pair if active, else the stashed one."""
309
+ if cfg.region == region:
310
+ return cfg.tokens
311
+ stashed = cfg.sessions.get(region)
312
+ return stashed.tokens if stashed else Tokens()
313
+
314
+
315
+ def save_tokens(cfg: Config) -> Path:
316
+ """Persist ``cfg``'s token pair for ``cfg.region`` without anything else.
317
+
318
+ Re-reads the file and only updates this region's tokens, so a refresh
319
+ happening in one process (e.g. the VSCode extension's ``delos serve``)
320
+ can't clobber another region's session, flip the file's active region,
321
+ or overwrite identity fields written since. Callers should hold
322
+ :func:`config_file_lock` around their read-refresh-save sequence.
323
+ """
324
+ disk = load()
325
+ if disk.region == cfg.region:
326
+ disk.tokens = cfg.tokens
327
+ else:
328
+ stashed = disk.sessions.get(cfg.region, Session())
329
+ stashed.tokens = cfg.tokens
330
+ disk.sessions[cfg.region] = stashed
331
+ return save(disk)
332
+
333
+
242
334
  @contextmanager
243
335
  def config_file_lock() -> Iterator[None]:
244
336
  """Advisory cross-process exclusive lock on ``~/.config/delos/.config.lock``.
@@ -333,34 +333,44 @@ def _pick_factor(factors: list[MfaFactor], on_message: Callable[[str], None]) ->
333
333
 
334
334
 
335
335
  HTTP_BAD_REQUEST = 400
336
+ HTTP_TOO_MANY_REQUESTS = 429
337
+ HTTP_SERVER_ERROR = 500
336
338
 
337
339
 
338
340
  async def refresh_tokens(cfg: Config) -> Tokens:
339
- """Swap the refresh token for a fresh access token at /api/oauth/token.
340
-
341
- Raises :class:`RefreshTokenInvalidError` when the server reports
342
- ``invalid_grant`` (token revoked, expired, or already rotated by another
343
- client) — the caller should prompt for re-login. Other failures raise
344
- :class:`OAuthError` and are generally transient.
341
+ """Swap the refresh token for a fresh pair, directly against GoTrue.
342
+
343
+ Our refresh tokens ARE Supabase refresh tokens (the web ``/api/oauth/token``
344
+ refresh grant just proxies to ``auth.refreshSession``), so we talk to
345
+ ``{supabase_url}/auth/v1/token`` like every other Supabase client does.
346
+ This keeps the web frontend out of the refresh path — in the dev region
347
+ the Next server may be down or HTTPS-only, and that must not read as an
348
+ auth failure.
349
+
350
+ Raises :class:`RefreshTokenInvalidError` only when GoTrue rejects the
351
+ token itself (4xx) — the caller should prompt for re-login. Everything
352
+ else (network, TLS, 5xx) raises :class:`OAuthError` and is transient.
345
353
  """
346
354
  if not cfg.tokens.refresh_token:
347
355
  msg = "no refresh_token stored — run `delos login` again"
348
356
  raise RefreshTokenInvalidError(msg)
349
- async with httpx.AsyncClient(timeout=30.0) as http:
350
- resp = await http.post(
351
- f"{cfg.web_url}/api/oauth/token",
352
- json={
353
- "grant_type": "refresh_token",
354
- "refresh_token": cfg.tokens.refresh_token,
355
- "client_id": CLIENT_ID,
356
- },
357
- )
358
- if resp.status_code == HTTP_BAD_REQUEST:
357
+ try:
358
+ async with httpx.AsyncClient(timeout=30.0) as http:
359
+ resp = await http.post(
360
+ f"{cfg.supabase_url}/auth/v1/token?grant_type=refresh_token",
361
+ json={"refresh_token": cfg.tokens.refresh_token},
362
+ headers={"apikey": cfg.supabase_anon_key},
363
+ )
364
+ except httpx.HTTPError as e:
365
+ msg = f"refresh failed: {type(e).__name__}: {e}"
366
+ raise OAuthError(msg) from e
367
+ if HTTP_BAD_REQUEST <= resp.status_code < HTTP_SERVER_ERROR and resp.status_code != HTTP_TOO_MANY_REQUESTS:
368
+ detail = "refresh token is invalid or expired"
359
369
  with contextlib.suppress(json.JSONDecodeError, ValueError):
360
370
  err = resp.json()
361
- if isinstance(err, dict) and err.get("error") == "invalid_grant":
362
- msg = str(err.get("error_description") or "refresh token is invalid or expired")
363
- raise RefreshTokenInvalidError(msg)
371
+ if isinstance(err, dict):
372
+ detail = str(err.get("msg") or err.get("error_description") or detail)
373
+ raise RefreshTokenInvalidError(detail)
364
374
  if resp.status_code >= HTTP_ERROR_THRESHOLD:
365
375
  msg = f"refresh failed: HTTP {resp.status_code}: {resp.text[:300]}"
366
376
  raise OAuthError(msg)
@@ -31,7 +31,7 @@ import json
31
31
  import time
32
32
  from typing import TYPE_CHECKING
33
33
 
34
- from .config import Tokens, config_file_lock, load, save
34
+ from .config import Tokens, config_file_lock, load, region_tokens, save_tokens
35
35
  from .oauth import refresh_tokens
36
36
 
37
37
  if TYPE_CHECKING:
@@ -91,17 +91,20 @@ class TokenManager:
91
91
 
92
92
  # Cross-process lock. Re-read the file inside it — another CLI
93
93
  # may have refreshed and written fresh tokens since we started.
94
+ # Compare against OUR region's tokens: the file's active region
95
+ # can differ (e.g. `delos serve --region X` next to a terminal
96
+ # signed into Y) and cross-region pairs must never be adopted.
94
97
  with config_file_lock():
95
- disk = load()
96
- if not force and _is_fresher(disk.tokens, self._cfg.tokens):
97
- self._cfg.tokens = disk.tokens
98
+ disk_tokens = region_tokens(load(), self._cfg.region)
99
+ if not force and _is_fresher(disk_tokens, self._cfg.tokens):
100
+ self._cfg.tokens = disk_tokens
98
101
  return
99
102
 
100
103
  # We're the one doing the refresh. Use whichever refresh
101
104
  # token is most recent (ours if unchanged, or the one from
102
105
  # disk if another process wrote a newer pair we missed).
103
- if _is_fresher(disk.tokens, self._cfg.tokens):
104
- self._cfg.tokens = disk.tokens
106
+ if _is_fresher(disk_tokens, self._cfg.tokens):
107
+ self._cfg.tokens = disk_tokens
105
108
 
106
109
  # On failure (RefreshTokenInvalidError / OAuthError) we
107
110
  # let the exception propagate and intentionally do NOT
@@ -110,7 +113,7 @@ class TokenManager:
110
113
  # shouldn't cost the user their session. The caller
111
114
  # surfaces "run delos login" and the user decides.
112
115
  self._cfg.tokens = await refresh_tokens(self._cfg)
113
- save(self._cfg)
116
+ save_tokens(self._cfg)
114
117
 
115
118
 
116
119
  def _jwt_exp(access_token: str) -> int | None:
@@ -40,6 +40,8 @@ as you go. If no plan exists in this conversation, say so instead of \
40
40
  improvising one."""
41
41
 
42
42
  #: Catalogue served to frontends (``list_commands`` RPC) for autocomplete.
43
+ #: ``/context`` is not a prompt expansion — serve intercepts it and answers
44
+ #: locally — but it belongs in the same autocomplete list.
43
45
  PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
44
46
  {
45
47
  "name": "/init",
@@ -49,6 +51,10 @@ PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
49
51
  "name": "/execute",
50
52
  "summary": "execute the plan from the previous plan-mode turn (loads it into todos)",
51
53
  },
54
+ {
55
+ "name": "/context",
56
+ "summary": "show what fills the agent's context window (token breakdown)",
57
+ },
52
58
  )
53
59
 
54
60