nxplora 0.16.0__py3-none-any.whl

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.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: nxplora
3
+ Version: 0.16.0
4
+ Summary: NX in your terminal, on your Nexplora account.
5
+ License: Proprietary
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: certifi
8
+ Requires-Dist: textual<2,>=1.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # nx-cli
12
+
13
+ **Private source. Proprietary. Published to PyPI as `nxplora` only when ready.**
14
+
15
+ The NX terminal client: a thin client of a person's Nexplora account. Each message is a real run on the same engine the
16
+ chat uses, with the account's approvals and billing. It shows what really came back and nothing else.
17
+
18
+ ## Charter
19
+
20
+ | nx-cli OWNS | nx-cli NEVER contains |
21
+ |---|---|
22
+ | Sign-in / enrollment with nx-cloud | Brain logic: routing, prompts, CodeChat harness, council, cognition router, brain core (these stay in private `Nexploraai/NX`) |
23
+ | Terminal UI, streaming, rendering | Model IDs, provider URLs or provider keys |
24
+ | Actions that must run on the operator's own machine (host, computer, phone) — only under nx-cloud-issued, approval-gated commands | Global Nexplora / NX secrets |
25
+ | Its own update channel | Any upload / publish path to a package index other than the gated release job |
26
+
27
+ Every published build must pass a check that none of the private NX modules are present in the wheel.
28
+
29
+ ## What it does
30
+
31
+ - **In a Nexplora Code session's terminal** it is already signed in: the app writes a short-lived session ticket (bound to the
32
+ person, the Operation and that session, one hour, usable only for sending a message there) to a private file, and renews it
33
+ while the page holds the terminal. The person's login is never on that computer. The ticket can't approve anything.
34
+ - **On any other computer** `nx` shows a short link and a code. Approve it in the browser and it's done. The sign-in stays in
35
+ `~/.nx-cli/credentials.json` (0600). `nx logout` ends it. Work runs on the person's Nexplora cloud computer, not this one.
36
+ - **Approvals** are given in the chat. `nx` says what is waiting and where, then follows the run as it goes on.
37
+ - **Colours** are the terminal's own, with no painted background, so it matches a light or dark panel.
38
+
39
+ ```bash
40
+ python -m pip install .
41
+ nx
42
+ ```
43
+
44
+ ## Checks
45
+
46
+ ```bash
47
+ python -m pytest # includes a real local HTTP server (tests/stub_server.py)
48
+ python -m build --wheel && python scripts/check_wheel.py dist/*.whl # the charter's check; a release must pass it
49
+ python scripts/pty_proof.py "$(command -v nx)" /tmp/nx-proof # real pty, reads the screen, fails on any painted background
50
+ ```
51
+
52
+ ## PyPI
53
+
54
+ The `nxplora` project name is owned and all historical releases are yanked (2026-09-29). Existing installs
55
+ run `nx upgrade` → `pip install -U nxplora`, so the first nx-cli release published under `nxplora` becomes
56
+ the migration path for those users. Never delete the PyPI project (name-takeover risk).
@@ -0,0 +1,11 @@
1
+ nxplora_cli/__init__.py,sha256=3Msc5baw88UJubVj5AVFB8tExkT2OFIsNWe2leaoHhc,23
2
+ nxplora_cli/__main__.py,sha256=IZXKBzDjyPsmZMJx7kOlZWGlUPsup6PU3UB62qEPPy8,1656
3
+ nxplora_cli/app.py,sha256=1CjjZYM0lPTDrATtEkV_kjc3GCNE0RcYno7LBvPZrMg,19834
4
+ nxplora_cli/auth.py,sha256=cDH8W5YRT1-v5WPz_Cf-2tpTHaV21GWeYEEiM5HNS7U,5118
5
+ nxplora_cli/events.py,sha256=X10BXDHNkc80UC_5MxIFIkN_tjUXHxurZkEFJdafHy8,3447
6
+ nxplora_cli/opening.py,sha256=MU_UUQV3Gr74O_RK86TZcXcdFHMY-EBsEZcCTiwF1OQ,8426
7
+ nxplora_cli/transport.py,sha256=NlDCUJShVDPIMfAiseNBo3zTn79xJw9nDBjJJDAp0x0,9552
8
+ nxplora-0.16.0.dist-info/METADATA,sha256=CjmxfmQb7TCi7Ft9WiTtXzPCPY6AlVNV9hYb_gAM2L0,2841
9
+ nxplora-0.16.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ nxplora-0.16.0.dist-info/entry_points.txt,sha256=SlKpaKzRQ9mGKAylpk5N5zY03oHQC08luALiFGdtmfw,85
11
+ nxplora-0.16.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ nx = nxplora_cli.__main__:main
3
+ nxplora = nxplora_cli.__main__:main
@@ -0,0 +1 @@
1
+ __version__ = "0.16.0"
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__
7
+ from .auth import DEFAULT_ORIGIN, forget_device_login, load_device_login, safe_origin
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(prog="nx", description="NX in your terminal, on your Nexplora account")
12
+ parser.add_argument("--version", action="version", version=__version__)
13
+ parser.add_argument("--connect", metavar="URL", help="the Nexplora address to use (default: nexplora.ai)")
14
+ parser.add_argument("command", nargs="?", choices=["login", "logout"], help="sign in or out on this computer")
15
+ args = parser.parse_args()
16
+ origin = safe_origin(args.connect) if args.connect else None
17
+ if args.connect and not origin:
18
+ parser.error("that address isn't https, so nx won't send your sign-in to it")
19
+ if args.command == "logout":
20
+ _logout()
21
+ return
22
+ from .app import NxploraApp
23
+
24
+ app = NxploraApp(origin=origin or DEFAULT_ORIGIN, origin_chosen=bool(origin), force_login=args.command == "login")
25
+ app.run(mouse=False)
26
+ if app.exit_note:
27
+ print(app.exit_note)
28
+
29
+
30
+ def _logout() -> None:
31
+ from .transport import Unreachable, revoke_device_session
32
+
33
+ login = load_device_login()
34
+ if login and login.device_session_id:
35
+ try:
36
+ revoke_device_session(login.origin, login.access_token, login.device_session_id)
37
+ except Unreachable:
38
+ print("Signed out here, but Nexplora couldn't be reached. You can end this sign-in in your account.", file=sys.stderr)
39
+ forget_device_login()
40
+ print("Signed out on this computer.")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
nxplora_cli/app.py ADDED
@@ -0,0 +1,422 @@
1
+ """The terminal. A thin client: each message is a real run on the person's account, and all it shows is what really came back.
2
+
3
+ Colours: everything is the terminal's own (ansi_default and the 16 named colours), so nothing paints a box. A panel's theme
4
+ decides what they look like, light or dark. The old screen painted #edf1eb; on the terminal panel's 16-colour TERM that
5
+ became ANSI 15, which the panel's light theme sets to its dark ink, and that was the dark box.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import platform
10
+ import threading
11
+ import time
12
+ from typing import Any, Callable
13
+
14
+ from rich.text import Text
15
+ from textual.app import App, ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.containers import Vertical, VerticalScroll
18
+ from textual.widgets import Input, Static
19
+
20
+ from . import __version__, transport
21
+ from .auth import (
22
+ DeviceLogin,
23
+ SessionTicket,
24
+ forget_device_login,
25
+ load_device_login,
26
+ on_nexplora_computer,
27
+ read_session_ticket,
28
+ safe_origin,
29
+ save_device_login,
30
+ )
31
+ from .events import Line, clean, line_for
32
+ from .opening import WorkspaceContext, default_opening, inspect_workspace
33
+
34
+ APPROVE_BESIDE = "Approve or deny it in the chat beside this terminal."
35
+ TICKET_OUT = "This terminal's sign-in has run out. Close the terminal panel and open it again."
36
+ TICKET_MISSING = "This terminal isn't signed in. Close the terminal panel and open it again."
37
+ UNREACHABLE = "Can't reach Nexplora. Check the connection and try again."
38
+ HELP = "Type a message and press Enter. Esc stops the run. Page Up and Page Down scroll. /clear clears this screen. /quit leaves."
39
+ _TONE_STYLE = {"assistant": "", "step": "bright_black", "waiting": "bold yellow", "problem": "red", "you": "bold"}
40
+ _EVENT_POLL_S = 2.0
41
+ _TICKET_POLL_S = 1.0
42
+ _TICKET_WAIT_S = 8.0
43
+
44
+
45
+ class NxploraApp(App[None]):
46
+ TITLE = "nx"
47
+ ENABLE_COMMAND_PALETTE = False
48
+ CSS = """
49
+ Screen { background: ansi_default; color: ansi_default; layers: base; }
50
+ #brand { height: 1; margin: 1 2 0 2; background: ansi_default; color: ansi_bright_black; }
51
+ #signin { height: 1fr; margin: 2 2 0 2; background: ansi_default; color: ansi_default; }
52
+ #signin Static { height: auto; margin-bottom: 1; background: ansi_default; color: ansi_default; }
53
+ #transcript { height: 1fr; margin: 1 2 0 2; background: ansi_default; color: ansi_default; scrollbar-size: 0 0; }
54
+ .line { height: auto; margin-bottom: 1; background: ansi_default; color: ansi_default; }
55
+ #status { height: 1; margin: 0 2; background: ansi_default; color: ansi_bright_black; }
56
+ #prompt { height: 1; border: none; margin: 0 2 1 2; padding: 0; background: ansi_default; color: ansi_default; }
57
+ #prompt:focus { border: none; background: ansi_default; }
58
+ Input > .input--cursor { background: ansi_default; color: ansi_default; text-style: reverse; }
59
+ Input > .input--placeholder { background: ansi_default; color: ansi_bright_black; }
60
+ Input > .input--suggestion { background: ansi_default; color: ansi_bright_black; }
61
+ Input > .input--selection { background: ansi_default; color: ansi_default; text-style: reverse; }
62
+ """
63
+ BINDINGS = [
64
+ Binding("escape", "stop_run", "Stop", priority=True),
65
+ Binding("ctrl+c", "quit", "Quit", priority=True),
66
+ Binding("pageup", "page(-1)", show=False, priority=True),
67
+ Binding("pagedown", "page(1)", show=False, priority=True),
68
+ ]
69
+
70
+ def __init__(
71
+ self,
72
+ origin: str = "https://nexplora.ai",
73
+ origin_chosen: bool = False,
74
+ force_login: bool = False,
75
+ workspace: str | None = None,
76
+ opening_provider: Callable[[WorkspaceContext], str] | None = None,
77
+ ) -> None:
78
+ super().__init__(ansi_color=True)
79
+ self.theme = "textual-ansi"
80
+ self.origin = origin
81
+ self.origin_chosen = origin_chosen
82
+ self.force_login = force_login
83
+ self.workspace = inspect_workspace(workspace)
84
+ self.opening_provider = opening_provider
85
+ self.exit_note = ""
86
+ self.ticket: SessionTicket | None = None
87
+ self.login: DeviceLogin | None = None
88
+ self.running = False
89
+ self.waiting_for_ok = False
90
+ self._stop_following = threading.Event()
91
+ self._quitting = threading.Event()
92
+ self._last_assistant: Static | None = None
93
+ self._last_assistant_text = ""
94
+
95
+ # ── never show a credential: Textual's crash report prints local variables, so it isn't used ──
96
+ def _fatal_error(self) -> None:
97
+ self.exit_note = "NX CLI hit a problem and closed. Run nx to start again."
98
+ self._close_messages_no_wait()
99
+
100
+ # ── screens ────────────────────────────────────────────────────────────────
101
+ def compose(self) -> ComposeResult:
102
+ yield Static("nx · Nexplora", id="brand")
103
+ yield Vertical(id="signin")
104
+
105
+ async def on_mount(self) -> None:
106
+ ticket = read_session_ticket()
107
+ if on_nexplora_computer() or ticket:
108
+ await self._say_signin("Signing in…")
109
+ self.run_worker(self._wait_for_ticket, thread=True, exclusive=True, group="signin")
110
+ return
111
+ login = None if self.force_login else load_device_login(self.origin if self.origin_chosen else None)
112
+ if login:
113
+ self.login = login
114
+ await self._open_work()
115
+ return
116
+ await self._say_signin("Getting a sign-in link…")
117
+ self.run_worker(self._device_grant, thread=True, exclusive=True, group="signin")
118
+
119
+ async def _say_signin(self, *lines: str) -> None:
120
+ box = self.query_one("#signin")
121
+ await box.remove_children()
122
+ for line in lines:
123
+ await box.mount(Static(Text(clean(line))))
124
+
125
+ async def _open_work(self) -> None:
126
+ await self.query_one("#signin").remove()
127
+ transcript = VerticalScroll(id="transcript", can_focus=False)
128
+ await self.mount(transcript)
129
+ await self.mount(Static("", id="status"))
130
+ await self.mount(Input(placeholder="Message NX", id="prompt"))
131
+ opening = self.opening_provider(self.workspace) if self.opening_provider else default_opening(self.workspace)
132
+ self._write(Line(opening, "assistant"))
133
+ if self.login:
134
+ self._write(Line("This runs on your Nexplora cloud computer, not on this one, and it doesn't see files here.", "step"))
135
+ self._last_assistant = None
136
+ self.query_one("#prompt", Input).focus()
137
+
138
+ # ── signing in ─────────────────────────────────────────────────────────────
139
+ def _wait_for_ticket(self) -> None:
140
+ """On a Code session's computer the app puts the ticket here. Nothing to do but see it arrive."""
141
+ said = False
142
+ deadline = time.time() + _TICKET_WAIT_S
143
+ while not self._quitting.is_set():
144
+ ticket = read_session_ticket()
145
+ if ticket and ticket.fresh():
146
+ self.ticket = ticket
147
+ self.call_from_thread(self._open_work)
148
+ return
149
+ if not said and time.time() > deadline:
150
+ said = True
151
+ self.call_from_thread(self._say_signin, TICKET_OUT if ticket else TICKET_MISSING)
152
+ time.sleep(_TICKET_POLL_S)
153
+
154
+ def _device_grant(self) -> None:
155
+ while not self._quitting.is_set():
156
+ try:
157
+ grant = transport.start_device_grant(self.origin, __version__, platform.node()[:128])
158
+ except transport.Unreachable:
159
+ self._signin_line(UNREACHABLE)
160
+ if self._quitting.wait(5):
161
+ return
162
+ continue
163
+ except transport.Refused as refused:
164
+ self._signin_line(str(refused))
165
+ if self._quitting.wait(10):
166
+ return
167
+ continue
168
+ self._signin_line("Sign in to Nexplora", f"Open this link: {grant.link}", f"Your code is {grant.user_code}", "Waiting for you to approve it there…")
169
+ outcome = self._await_grant(grant)
170
+ if outcome == "signed_in" or outcome == "quit":
171
+ return
172
+ if outcome == "denied":
173
+ self._signin_line("That sign-in was denied. Run nx to try again, or Ctrl+C to leave.")
174
+ return
175
+ # expired: a fresh code, without asking
176
+
177
+ def _await_grant(self, grant: transport.DeviceCode) -> str:
178
+ deadline = time.time() + grant.expires_in_s
179
+ while time.time() < deadline:
180
+ if self._quitting.wait(max(1.0, grant.interval_s)):
181
+ return "quit"
182
+ try:
183
+ result = transport.poll_device_grant(self.origin, grant.device_code)
184
+ except transport.Unreachable:
185
+ continue
186
+ if isinstance(result, dict):
187
+ device_session = result.get("device_session_id")
188
+ self.login = DeviceLogin(
189
+ self.origin, str(result["access_token"]), str(result.get("refresh_token") or ""),
190
+ device_session if isinstance(device_session, str) else None,
191
+ )
192
+ save_device_login(self.login)
193
+ self.call_from_thread(self._open_work)
194
+ return "signed_in"
195
+ if result in ("denied", "expired"):
196
+ return str(result)
197
+ return "expired"
198
+
199
+ def _signin_line(self, *lines: str) -> None:
200
+ self.call_from_thread(self._say_signin, *lines)
201
+
202
+ # ── a credential, fresh, for one request ───────────────────────────────────
203
+ def _credential(self) -> tuple[str, str, str]:
204
+ """(origin, bearer, session id). Raises Refused with the one plain line when there is none."""
205
+ if self.login:
206
+ login = self.login
207
+ if not login.session_id:
208
+ login.session_id = self._with_refresh(lambda bearer: transport.create_session(login.origin, bearer))
209
+ save_device_login(login)
210
+ return login.origin, login.access_token, login.session_id
211
+ ticket = read_session_ticket()
212
+ if not ticket:
213
+ raise transport.Refused(TICKET_MISSING)
214
+ if not ticket.fresh():
215
+ raise transport.Refused(TICKET_OUT)
216
+ return ticket.origin, ticket.ticket, ticket.session_id
217
+
218
+ def _with_refresh(self, call: Callable[[str], Any]) -> Any:
219
+ login = self.login
220
+ assert login is not None
221
+ try:
222
+ return call(login.access_token)
223
+ except transport.SignedOut:
224
+ tokens = transport.refresh_login(login.origin, login.refresh_token) if login.refresh_token else None
225
+ if not tokens:
226
+ raise
227
+ login.access_token, login.refresh_token = tokens
228
+ save_device_login(login)
229
+ return call(login.access_token)
230
+
231
+ def _signed_out_line(self) -> str:
232
+ if self.login:
233
+ forget_device_login()
234
+ return "You're signed out. Run nx to sign in again."
235
+ return TICKET_OUT
236
+
237
+ # ── a message ──────────────────────────────────────────────────────────────
238
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
239
+ text = event.value.strip()
240
+ event.input.value = ""
241
+ if not text:
242
+ return
243
+ if text == "/quit":
244
+ self.exit()
245
+ return
246
+ if text == "/clear":
247
+ await self.query_one("#transcript").remove_children()
248
+ self._last_assistant = None
249
+ return
250
+ if text == "/help":
251
+ self._write(Line(HELP, "step"))
252
+ return
253
+ if self.running:
254
+ self._write(Line("NX is still on the last message. Wait for it, or press Esc to stop it.", "step"))
255
+ return
256
+ self._write(Line(f"› {text}", "you"))
257
+ self._last_assistant = None
258
+ self.running = True
259
+ self._stop_following.clear()
260
+ self._status("Working…")
261
+ self.run_worker(lambda: self._run_turn(text), thread=True, exclusive=False, group="turn")
262
+
263
+ def _run_turn(self, task: str) -> None:
264
+ try:
265
+ self._drive(task)
266
+ except transport.SignedOut:
267
+ self._show(Line(self._signed_out_line(), "problem"))
268
+ except transport.Refused as refused:
269
+ self._show(Line(str(refused), "problem"))
270
+ except transport.Unreachable:
271
+ self._show(Line(UNREACHABLE, "problem"))
272
+ except Exception: # noqa: BLE001 - a turn that breaks says so in plain words; the screen stays usable
273
+ self._show(Line("Something went wrong showing that. Check the chat for what happened.", "problem"))
274
+ finally:
275
+ self.call_from_thread(self._turn_ended)
276
+
277
+ def _turn_ended(self) -> None:
278
+ self.running = False
279
+ self.waiting_for_ok = False
280
+ self._status("")
281
+
282
+ def _drive(self, task: str) -> None:
283
+ tick = 0
284
+ while True:
285
+ origin, bearer, session_id = self._credential()
286
+ try:
287
+ if self.login:
288
+ stream = self._with_refresh(lambda b: transport.post_run(origin, b, session_id, task, tick))
289
+ else:
290
+ stream = transport.post_run(origin, bearer, session_id, task, tick)
291
+ except transport.Refused as refused:
292
+ if self.login and "session is gone" in str(refused):
293
+ self.login.session_id = None
294
+ self._show(Line("That session is gone, so this starts a new one.", "step"))
295
+ continue
296
+ raise
297
+ seen = 0
298
+ next_tick: int | None = None
299
+ finished: str | None = None
300
+ try:
301
+ for event in stream.events:
302
+ seen += 1
303
+ kind = event.get("event")
304
+ if kind == "checkpoint":
305
+ last = event.get("lastTick")
306
+ next_tick = last if isinstance(last, int) else tick
307
+ elif kind == "done":
308
+ finished = str(event.get("status") or "completed")
309
+ elif kind == "error":
310
+ finished = "error"
311
+ self._render_event(event)
312
+ except transport.Unreachable:
313
+ self._show(Line("The connection dropped. Following the run from what it logged…", "step"))
314
+ if finished == "completed" or finished == "error":
315
+ return
316
+ if finished == "awaiting_approval" or finished == "awaiting_plan":
317
+ self.waiting_for_ok = True
318
+ self.call_from_thread(self._status, "Waiting for your OK. Esc stops watching here; the change keeps waiting in the chat.")
319
+ self._follow(stream.run_id, seen, session_id)
320
+ return
321
+ if next_tick is not None:
322
+ tick = next_tick
323
+ continue
324
+ self._follow(stream.run_id, seen, session_id)
325
+ return
326
+
327
+ def _follow(self, run_id: str | None, seq: int, session_id: str) -> None:
328
+ """Read the run's own log until it ends: a change approved in the chat, or a stream that dropped, shows as it went."""
329
+ if not run_id:
330
+ self._show(Line("Follow the rest of this in the chat beside this terminal.", "step"))
331
+ return
332
+ current_run = run_id
333
+ while not self._stop_following.is_set() and not self._quitting.is_set():
334
+ origin, bearer, _ = self._credential()
335
+ if self.login:
336
+ events, seq, current_run = self._with_refresh(lambda b: transport.read_events(origin, b, session_id, current_run, seq))
337
+ else:
338
+ events, seq, current_run = transport.read_events(origin, bearer, session_id, current_run, seq)
339
+ ended = False
340
+ for event in events:
341
+ self._render_event(event)
342
+ kind = event.get("event")
343
+ if kind == "done":
344
+ status = str(event.get("status") or "completed")
345
+ ended = status not in ("awaiting_approval", "awaiting_plan")
346
+ elif kind == "error":
347
+ ended = True
348
+ elif kind == "tool_call":
349
+ self.waiting_for_ok = False
350
+ self.call_from_thread(self._status, "Working…")
351
+ if ended:
352
+ return
353
+ if self._stop_following.wait(_EVENT_POLL_S):
354
+ return
355
+
356
+ def _render_event(self, event: dict[str, Any]) -> None:
357
+ where = APPROVE_BESIDE
358
+ if self.login and self.login.session_id:
359
+ where = f"Approve or deny it in Nexplora: {self.login.origin}/code?codeSession={self.login.session_id}"
360
+ line = line_for(event, where)
361
+ if line:
362
+ self._show(line)
363
+
364
+ # ── the screen ─────────────────────────────────────────────────────────────
365
+ def _show(self, line: Line) -> None:
366
+ self.call_from_thread(self._write, line)
367
+
368
+ def _write(self, line: Line) -> None:
369
+ if not self.query("#transcript"):
370
+ return
371
+ transcript = self.query_one("#transcript", VerticalScroll)
372
+ if line.tone == "assistant" and self._last_assistant is not None:
373
+ text = line.text if line.replace_assistant else f"{self._last_assistant_text}\n\n{line.text}"
374
+ self._last_assistant.update(Text(text))
375
+ self._last_assistant_text = text
376
+ else:
377
+ widget = Static(Text(line.text, style=_TONE_STYLE.get(line.tone, "")), classes="line")
378
+ transcript.mount(widget)
379
+ self._last_assistant = widget if line.tone == "assistant" else None
380
+ self._last_assistant_text = line.text if line.tone == "assistant" else ""
381
+ transcript.scroll_end(animate=False)
382
+
383
+ def _status(self, text: str) -> None:
384
+ if self.query("#status"):
385
+ self.query_one("#status", Static).update(Text(text))
386
+
387
+ def action_page(self, direction: int) -> None:
388
+ if self.query("#transcript"):
389
+ box = self.query_one("#transcript", VerticalScroll)
390
+ if direction < 0:
391
+ box.scroll_page_up(animate=False)
392
+ else:
393
+ box.scroll_page_down(animate=False)
394
+
395
+ def action_stop_run(self) -> None:
396
+ if not self.running:
397
+ return
398
+ self._stop_following.set()
399
+ if self.waiting_for_ok:
400
+ return # nothing is running: it is waiting on the person, and Esc here must not throw that away
401
+ self._status("Stopping…")
402
+ self.run_worker(self._stop_on_server, thread=True, exclusive=False, group="stop")
403
+
404
+ def _stop_on_server(self) -> None:
405
+ try:
406
+ origin, bearer, session_id = self._credential()
407
+ if self.login:
408
+ self._with_refresh(lambda b: transport.stop_run(origin, b, session_id))
409
+ else:
410
+ transport.stop_run(origin, bearer, session_id)
411
+ except (transport.SignedOut, transport.Refused, transport.Unreachable):
412
+ self._show(Line("Couldn't stop it from here. Press Stop in the chat.", "problem"))
413
+
414
+ async def action_quit(self) -> None:
415
+ self._quitting.set()
416
+ self._stop_following.set()
417
+ if self.running:
418
+ self.exit_note = "NX may still be working in this session. It shows in the chat."
419
+ self.exit()
420
+
421
+
422
+ __all__ = ["NxploraApp", "safe_origin"]
nxplora_cli/auth.py ADDED
@@ -0,0 +1,151 @@
1
+ """How `nx` is signed in. Nothing here prints, logs or puts a credential anywhere but the one request it is for.
2
+
3
+ In a Nexplora Code session the app writes a short-lived session ticket to a private file (never the person's login). On any
4
+ other computer the person signs in once with a short link and code, and the result stays in a 0600 file.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import stat
11
+ import tempfile
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from urllib.parse import urlsplit
16
+
17
+ DEFAULT_ORIGIN = "https://nexplora.ai"
18
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "[::1]", "::1"}
19
+
20
+
21
+ def safe_origin(origin: object) -> str | None:
22
+ """Only https, or a local address. A credential is never sent anywhere else."""
23
+ if not isinstance(origin, str):
24
+ return None
25
+ parts = urlsplit(origin.strip())
26
+ if parts.path not in ("", "/") or parts.query or parts.fragment or parts.username or parts.password:
27
+ return None
28
+ if parts.scheme == "https" and parts.hostname:
29
+ return f"https://{parts.netloc}"
30
+ if parts.scheme == "http" and parts.hostname in _LOCAL_HOSTS:
31
+ return f"http://{parts.netloc}"
32
+ return None
33
+
34
+
35
+ def _home() -> Path:
36
+ return Path(os.path.expanduser("~"))
37
+
38
+
39
+ def _private(path: Path) -> bool:
40
+ """Ours, and nobody else can read it."""
41
+ try:
42
+ info = path.lstat()
43
+ except OSError:
44
+ return False
45
+ return stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() and not (info.st_mode & 0o077)
46
+
47
+
48
+ def session_file_paths() -> tuple[Path, Path]:
49
+ """Same order the app writes them in: tmpfs first, then the home folder."""
50
+ return (Path(f"/dev/shm/nx-cli-{os.getuid()}/session.json"), _home() / ".nx-cli" / "session.json")
51
+
52
+
53
+ def on_nexplora_computer() -> bool:
54
+ """A Code session's computer. Its terminal is set up by the app, which leaves this folder behind."""
55
+ return (_home() / ".nx-pty").is_dir()
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class SessionTicket:
60
+ origin: str
61
+ session_id: str
62
+ ticket: str
63
+ expires_at_ms: int
64
+
65
+ def __repr__(self) -> str:
66
+ return f"SessionTicket(session_id={self.session_id!r})"
67
+
68
+ def fresh(self, now_ms: float | None = None) -> bool:
69
+ return self.expires_at_ms > (time.time() * 1000 if now_ms is None else now_ms)
70
+
71
+
72
+ def read_session_ticket() -> SessionTicket | None:
73
+ """The ticket on this computer, read fresh each time so a renewal is picked up without restarting."""
74
+ for path in session_file_paths():
75
+ if not _private(path):
76
+ continue
77
+ try:
78
+ doc = json.loads(path.read_text("utf-8"))
79
+ except (OSError, ValueError):
80
+ continue
81
+ origin = safe_origin(doc.get("origin"))
82
+ ticket, session_id, expires = doc.get("ticket"), doc.get("session_id"), doc.get("expires_at_ms")
83
+ if origin and isinstance(ticket, str) and ticket and isinstance(session_id, str) and session_id and isinstance(expires, (int, float)):
84
+ return SessionTicket(origin, session_id, ticket, int(expires))
85
+ return None
86
+
87
+
88
+ def credentials_path() -> Path:
89
+ return _home() / ".nx-cli" / "credentials.json"
90
+
91
+
92
+ @dataclass
93
+ class DeviceLogin:
94
+ origin: str
95
+ access_token: str
96
+ refresh_token: str
97
+ device_session_id: str | None = None
98
+ session_id: str | None = None
99
+
100
+ def __repr__(self) -> str:
101
+ return f"DeviceLogin(origin={self.origin!r})"
102
+
103
+
104
+ def load_device_login(origin: str | None = None) -> DeviceLogin | None:
105
+ path = credentials_path()
106
+ if not _private(path):
107
+ return None
108
+ try:
109
+ doc = json.loads(path.read_text("utf-8"))
110
+ except (OSError, ValueError):
111
+ return None
112
+ stored = safe_origin(doc.get("origin"))
113
+ access, refresh = doc.get("access_token"), doc.get("refresh_token")
114
+ if not stored or not isinstance(access, str) or not access or not isinstance(refresh, str):
115
+ return None
116
+ if origin and stored != origin:
117
+ return None
118
+ session_id = doc.get("session_id")
119
+ device_session_id = doc.get("device_session_id")
120
+ return DeviceLogin(
121
+ stored, access, refresh,
122
+ device_session_id if isinstance(device_session_id, str) else None,
123
+ session_id if isinstance(session_id, str) else None,
124
+ )
125
+
126
+
127
+ def save_device_login(login: DeviceLogin) -> None:
128
+ path = credentials_path()
129
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
130
+ os.chmod(path.parent, 0o700)
131
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".credentials.") # mkstemp creates it 0600
132
+ try:
133
+ with os.fdopen(fd, "w", encoding="utf-8") as out:
134
+ json.dump({
135
+ "origin": login.origin, "access_token": login.access_token, "refresh_token": login.refresh_token,
136
+ "device_session_id": login.device_session_id, "session_id": login.session_id,
137
+ }, out)
138
+ os.replace(tmp, path)
139
+ except BaseException:
140
+ try:
141
+ os.unlink(tmp)
142
+ except OSError:
143
+ pass
144
+ raise
145
+
146
+
147
+ def forget_device_login() -> None:
148
+ try:
149
+ credentials_path().unlink()
150
+ except OSError:
151
+ pass
nxplora_cli/events.py ADDED
@@ -0,0 +1,75 @@
1
+ """What each thing the run really did looks like in the terminal. Nothing here is made up: no event, no line."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ CREDIT_PAUSE = "paused: credit limit"
8
+ OUT_OF_CREDIT_LINE = "You're out of credit. Add more in Nexplora to keep going."
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Line:
13
+ text: str
14
+ tone: str # assistant | step | waiting | problem
15
+ replace_assistant: bool = False
16
+
17
+
18
+ def clean(text: object, limit: int = 60_000) -> str:
19
+ """Words from the server may not move the cursor, change colours or fake a screen."""
20
+ if not isinstance(text, str):
21
+ return ""
22
+ return "".join(ch for ch in text[:limit] if ch in "\n\t" or (ord(ch) >= 0x20 and not 0x7f <= ord(ch) <= 0x9f))
23
+
24
+
25
+ def first_line(text: object, limit: int = 300) -> str:
26
+ cleaned = clean(text, 4_000).strip()
27
+ return cleaned.splitlines()[0][:limit] if cleaned else ""
28
+
29
+
30
+ def line_for(event: dict[str, Any], where_to_approve: str) -> Line | None:
31
+ kind = event.get("event")
32
+ if kind == "assistant_text":
33
+ text = clean(event.get("text"))
34
+ if text.strip() == CREDIT_PAUSE:
35
+ return Line(OUT_OF_CREDIT_LINE, "problem")
36
+ return Line(text, "assistant") if text.strip() else None
37
+ if kind == "assistant_revision":
38
+ text = clean(event.get("text"))
39
+ return Line(text, "assistant", True) if text.strip() else None
40
+ if kind in ("plan", "build_plan"):
41
+ text = clean(event.get("text"))
42
+ return Line(text, "step") if text.strip() else None
43
+ if kind == "tool_call":
44
+ summary = first_line(event.get("summary")) or first_line(event.get("tool"))
45
+ return Line(f"· {summary}", "step") if summary else None
46
+ if kind == "tool_result" and event.get("ok") is False:
47
+ detail = first_line(event.get("detail")) or first_line(event.get("stderr"))
48
+ return Line(f" That step didn't work{': ' + detail if detail else '.'}", "problem")
49
+ if kind == "file_changed":
50
+ path = first_line(event.get("path"))
51
+ return Line(f"· {clean(event.get('changeType')) or 'changed'} {path}", "step") if path else None
52
+ if kind == "approval_required":
53
+ summary = first_line(event.get("summary")) or "A change"
54
+ return Line(f"Waiting for your OK: {summary}\n{where_to_approve}", "waiting")
55
+ if kind == "images_generated":
56
+ urls = event.get("urls")
57
+ shown = [clean(u, 2_000) for u in urls if isinstance(u, str)] if isinstance(urls, list) else []
58
+ return Line("\n".join(shown), "step") if shown else None
59
+ if kind == "video_pending":
60
+ return Line("A video is being made. It will show in the chat.", "step")
61
+ if kind in ("web_operator_launch", "computer_operator_launch", "mission_operator_launch"):
62
+ return Line("This part runs outside the terminal. Follow it in Nexplora.", "step")
63
+ if kind == "halt":
64
+ detail = first_line(event.get("detail"))
65
+ reason = event.get("reason")
66
+ if reason == "aborted":
67
+ return Line("Stopped.", "step")
68
+ if reason in ("awaiting_approval", "awaiting_plan", "maxduration_checkpoint", "done", "session_busy"):
69
+ return None
70
+ return Line(detail or "The run stopped before it finished.", "problem")
71
+ if kind == "error":
72
+ from .transport import friendly
73
+
74
+ return Line(friendly(200, str(event.get("code") or ""), ""), "problem")
75
+ return None
nxplora_cli/opening.py ADDED
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import random
5
+ import re
6
+ import subprocess
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+
13
+ _INDEX_SCAN_LIMIT = 4096
14
+ _INDEX_STAT = re.compile(
15
+ r" ctime: \d+:\d+\n mtime: (\d+):(\d+)\n"
16
+ r" dev: \d+\tino: \d+\n uid: \d+\tgid: \d+\n"
17
+ r" size: (\d+)\tflags: [0-9a-f]+\n"
18
+ )
19
+
20
+
21
+ def _unstaged_paths(cwd: Path, index: str) -> list[str]:
22
+ # --stage --debug emits a NUL-terminated mode/OID/stage/path record,
23
+ # followed by five lines of index-only stat data. Never split paths on LF.
24
+ deadline = time.monotonic() + 0.7
25
+ offset = 0
26
+ changed = []
27
+ for _ in range(_INDEX_SCAN_LIMIT):
28
+ if time.monotonic() >= deadline:
29
+ break
30
+ end = index.find("\0", offset)
31
+ if end < 0:
32
+ break
33
+ entry = index[offset:end]
34
+ metadata = _INDEX_STAT.match(index, end + 1)
35
+ if metadata is None:
36
+ break # Unknown debug format: keep the other sources of context.
37
+ offset = metadata.end()
38
+ mode, separator, path = entry.partition("\t")
39
+ if not separator or mode.startswith("160000 "):
40
+ continue # Do not inspect gitlinks/submodule directories.
41
+ try:
42
+ info = os.lstat(cwd / path)
43
+ except (OSError, ValueError):
44
+ changed.append(path)
45
+ continue
46
+ seconds, nanoseconds, size = map(int, metadata.groups())
47
+ # Some Git platforms store only whole-second index timestamps.
48
+ if (info.st_size != size
49
+ or info.st_mtime_ns // 1_000_000_000 != seconds
50
+ or (nanoseconds and info.st_mtime_ns % 1_000_000_000 != nanoseconds)):
51
+ changed.append(path)
52
+ return changed
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class WorkspaceContext:
57
+ directory: str
58
+ branch: str | None
59
+ changed_files: tuple[str, ...]
60
+ latest_commit: str | None
61
+
62
+ @property
63
+ def has_work(self) -> bool:
64
+ return bool(self.branch or self.changed_files or self.latest_commit)
65
+
66
+
67
+ def inspect_workspace(directory: str | None = None) -> WorkspaceContext:
68
+ cwd = Path(directory or os.getcwd())
69
+ # Startup may inspect an untrusted checkout before the user signs in.
70
+ # Do not inherit credentials, Git config injection, or user/system config.
71
+ env = {
72
+ "PATH": os.environ.get("PATH", os.defpath),
73
+ "LC_ALL": "C",
74
+ "GIT_CONFIG_NOSYSTEM": "1",
75
+ "GIT_CONFIG_SYSTEM": os.devnull,
76
+ "GIT_CONFIG_GLOBAL": os.devnull,
77
+ "GIT_OPTIONAL_LOCKS": "0",
78
+ "GIT_TERMINAL_PROMPT": "0",
79
+ # Partial clones can otherwise invoke repo-configured remote helpers.
80
+ "GIT_NO_LAZY_FETCH": "1",
81
+ "GIT_ALLOW_PROTOCOL": "",
82
+ }
83
+ if os.name == "nt" and "SystemRoot" in os.environ:
84
+ env["SystemRoot"] = os.environ["SystemRoot"]
85
+
86
+ def git(*args: str) -> str | None:
87
+ try:
88
+ result = subprocess.run(
89
+ [
90
+ "git", "-c", "core.fsmonitor=false",
91
+ "-c", "core.untrackedCache=false",
92
+ "-c", f"core.hooksPath={os.devnull}", *args,
93
+ ],
94
+ cwd=cwd, env=env, capture_output=True, text=True,
95
+ encoding="utf-8", errors="replace", timeout=0.7, check=False,
96
+ )
97
+ except (OSError, subprocess.TimeoutExpired, UnicodeDecodeError, ValueError):
98
+ return None
99
+ return result.stdout if result.returncode == 0 else None
100
+
101
+ # Never inspect worktree contents: status/worktree diffs can run filters or
102
+ # enter submodules. The plumbing command compares index paths with HEAD;
103
+ # unlike `diff`, it has no auto-refresh fallback or configured order file.
104
+ # Staged gitlinks remain visible. Fall back to index paths without HEAD.
105
+ staged = git(
106
+ "diff-index", "--cached", "--name-only", "--no-ext-diff", "--no-textconv",
107
+ "--no-renames", "--ignore-submodules=none", "--no-relative",
108
+ "-z", "HEAD", "--",
109
+ )
110
+ if staged is None and git("rev-parse", "--verify", "-q", "HEAD") is None:
111
+ staged = git("ls-files", "-z")
112
+ # ls-files reads index metadata and walks directory names only. Python
113
+ # compares stat data without opening files; this is deliberately a bounded,
114
+ # best-effort hint (same-size edits preserving mtime may be missed).
115
+ unstaged = _unstaged_paths(cwd, git("ls-files", "--stage", "--debug", "-z") or "")
116
+ untracked = git(
117
+ "ls-files", "-z", "--others", "--exclude-standard",
118
+ "--directory", "--no-empty-directory",
119
+ )
120
+ changed = dict.fromkeys(
121
+ unstaged + [path for path in (staged or "").split("\0") if path]
122
+ + [path for path in (untracked or "").split("\0") if path]
123
+ )
124
+ return WorkspaceContext(
125
+ directory=str(cwd),
126
+ branch=(git("symbolic-ref", "--short", "-q", "HEAD") or "").strip() or None,
127
+ changed_files=tuple(changed),
128
+ latest_commit=(git(
129
+ "log", "-1", "--no-show-signature", "--no-use-mailmap", "--format=%s",
130
+ ) or "").strip() or None,
131
+ )
132
+
133
+
134
+ def default_opening(context: WorkspaceContext, chooser: Callable[[list[str]], str] | None = None) -> str:
135
+ choose = chooser or random.SystemRandom().choice
136
+ if context.has_work:
137
+ branch = f" on `{context.branch}`" if context.branch else ""
138
+ extra = f" The last move was `{context.latest_commit}`." if context.latest_commit else ""
139
+ if context.changed_files:
140
+ focus = context.changed_files[0]
141
+ extra += f" I see work in `{focus}`."
142
+ return choose([
143
+ f"Welcome back{branch}.{extra} Want to finish it, inspect the diff, or keep building?",
144
+ f"I found an active workspace{branch}.{extra} What should we continue?",
145
+ f"Back in the work{branch}.{extra} Shall we pick up where you left off?",
146
+ ])
147
+ return choose([
148
+ "Hey — how are you doing? What’s on your mind?",
149
+ "Hey, what’s up? What are we building today?",
150
+ "Good to see you. What’s cooking?",
151
+ "Hey — where should we start?",
152
+ "What are you thinking about? NX is ready when you are.",
153
+ "Morning. What would make today’s work feel like a win?",
154
+ "Hey there — what’s the first thing you want to move forward?",
155
+ "What’s alive in your head right now?",
156
+ "Welcome in. Do you want to explore, make, or fix something?",
157
+ "Hey — got a spark to chase?",
158
+ "What are we making better today?",
159
+ "I’m here. What should we look at first?",
160
+ "What’s the shape of the problem you’re carrying in?",
161
+ "Hey — want to start with an idea or an action?",
162
+ "What do you want to get unstuck?",
163
+ "Good to have you back. What’s worth your attention right now?",
164
+ "What’s the interesting thing on your desk today?",
165
+ "Hey — should we make something, understand something, or ship something?",
166
+ "What would be useful to finish before you leave this session?",
167
+ "Tell me what’s brewing.",
168
+ "What’s the next good move?",
169
+ "Hey — where is your attention pulling you?",
170
+ "What do you want NX to help carry today?",
171
+ "What’s the question behind the task?",
172
+ "Welcome. What are we curious about?",
173
+ "Hey — want to turn a rough thought into a plan?",
174
+ "What’s the thing you keep meaning to get back to?",
175
+ "What should we make clear first?",
176
+ "I’m ready when you are. What’s happening?",
177
+ "Hey — what deserves a closer look?",
178
+ "What are you hoping to discover today?",
179
+ "Want to start small or take on the big thing?",
180
+ "What’s changing in your world right now?",
181
+ "Hey — what can we make lighter?",
182
+ "What would you like to have in motion by the end of this?",
183
+ "What’s the story behind today’s work?",
184
+ "Welcome back to the workbench. What’s next?",
185
+ "Hey — do you have a direction, or should we find one together?",
186
+ "What’s the most useful place to begin?",
187
+ "What are we solving for?",
188
+ "Hey — bring me the half-formed version.",
189
+ "What’s on your mind, even if it’s not fully formed yet?",
190
+ "Should we follow the signal or inspect the noise?",
191
+ "What do you want to make real today?",
192
+ "Hey — what’s calling for your attention?",
193
+ ])
194
+
@@ -0,0 +1,221 @@
1
+ """The whole of what `nx` says to Nexplora: send a message, follow what really happened, stop, and sign in.
2
+
3
+ Standard library only. No brain, no model, no provider: every step is the same run a message typed in the chat makes.
4
+ Redirects are never followed, so a credential only ever goes to the address it was issued for.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import http.client
9
+ import json
10
+ import ssl
11
+ import uuid
12
+ from dataclasses import dataclass
13
+ from typing import Any, Callable, Iterator
14
+ from urllib.parse import urlencode, urlsplit
15
+
16
+ _TIMEOUT_S = 30
17
+ # A run's stream may sit quiet for a long step. The server's own deadline ends it, so this is only for a dead connection.
18
+ _STREAM_TIMEOUT_S = 660
19
+ RUN_ID_HEADER = "x-nx-code-run-id"
20
+
21
+
22
+ class SignedOut(Exception):
23
+ """The credential was refused."""
24
+
25
+
26
+ class Refused(Exception):
27
+ """The server answered no. str() is one plain line for the person."""
28
+
29
+
30
+ class Unreachable(Exception):
31
+ """Nothing answered."""
32
+
33
+
34
+ def friendly(status: int, code: str, reason: str) -> str:
35
+ """One plain line for a refusal. A server's words are used only where they are already written for the person."""
36
+ if status == 402 or "credit" in code or "budget" in code:
37
+ return "You're out of credit. Add more in Nexplora to keep going."
38
+ if code == "session_not_found" or status == 404:
39
+ return "This session is gone. Open a new one in Nexplora."
40
+ if code == "session_busy":
41
+ return "This session is already working on something. Wait for it to finish, or stop it in the chat."
42
+ if code == "busy" or status == 429:
43
+ return "Nexplora is busy right now. Try again in a moment."
44
+ if code in {"workspace_expired", "workspace_unavailable"}:
45
+ return "This session's computer isn't available. Open a new session in Nexplora."
46
+ if code in {"approval_elsewhere", "ticket_message_only", "ticket_other_session"}:
47
+ return "This terminal can't do that. Do it in the chat beside it."
48
+ if status == 403 and reason:
49
+ return reason # a plan or compute refusal: the same words the app shows
50
+ if code == "model_unavailable":
51
+ return "That model isn't available right now. Try again in a moment."
52
+ if status >= 500 or code in {"backend_unavailable", "plan_unavailable", "not_configured", "request_timeout"}:
53
+ return "Nexplora couldn't do that just now. Try again in a moment."
54
+ return "That didn't go through. Try again in a moment."
55
+
56
+
57
+ def _connection(origin: str, timeout: float) -> http.client.HTTPConnection:
58
+ parts = urlsplit(origin)
59
+ if parts.scheme == "https":
60
+ context = ssl.create_default_context()
61
+ try:
62
+ import certifi
63
+
64
+ context.load_verify_locations(certifi.where())
65
+ except (ImportError, OSError, ssl.SSLError):
66
+ pass
67
+ return http.client.HTTPSConnection(parts.hostname or "", parts.port, timeout=timeout, context=context)
68
+ return http.client.HTTPConnection(parts.hostname or "", parts.port, timeout=timeout)
69
+
70
+
71
+ def _headers(bearer: str | None, has_body: bool, accept: str = "application/json") -> dict[str, str]:
72
+ headers = {"accept": accept, "user-agent": "nx-cli"}
73
+ if has_body:
74
+ headers["content-type"] = "application/json"
75
+ if bearer:
76
+ headers["authorization"] = f"Bearer {bearer}"
77
+ return headers
78
+
79
+
80
+ def _json_request(origin: str, method: str, path: str, bearer: str | None = None, body: object | None = None) -> tuple[int, dict[str, Any]]:
81
+ conn = _connection(origin, _TIMEOUT_S)
82
+ try:
83
+ conn.request(method, path, json.dumps(body) if body is not None else None, _headers(bearer, body is not None))
84
+ res = conn.getresponse()
85
+ raw = res.read(2_000_000)
86
+ try:
87
+ parsed = json.loads(raw) if raw else {}
88
+ except ValueError:
89
+ parsed = {}
90
+ return res.status, parsed if isinstance(parsed, dict) else {}
91
+ except (OSError, http.client.HTTPException) as err:
92
+ raise Unreachable() from err
93
+ finally:
94
+ conn.close()
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class RunStream:
99
+ run_id: str | None
100
+ events: Iterator[dict[str, Any]]
101
+
102
+
103
+ def post_run(origin: str, bearer: str, session_id: str, task: str, resume_from_tick: int = 0) -> RunStream:
104
+ """One run request. A refusal raises; otherwise the events arrive as the server emits them."""
105
+ body: dict[str, object] = {"sessionId": session_id, "task": task, "idempotencyKey": f"nx-cli:{uuid.uuid4()}"}
106
+ if resume_from_tick > 0:
107
+ body["resumeFromTick"] = resume_from_tick
108
+ conn = _connection(origin, _STREAM_TIMEOUT_S)
109
+ try:
110
+ conn.request("POST", "/api/code/run", json.dumps(body), _headers(bearer, True, "application/x-ndjson"))
111
+ res = conn.getresponse()
112
+ except (OSError, http.client.HTTPException) as err:
113
+ conn.close()
114
+ raise Unreachable() from err
115
+ if res.status == 401:
116
+ conn.close()
117
+ raise SignedOut()
118
+ if res.status >= 300:
119
+ raw = res.read(64_000)
120
+ conn.close()
121
+ code, reason = "", ""
122
+ for line in raw.decode("utf-8", "replace").splitlines():
123
+ try:
124
+ doc = json.loads(line)
125
+ except ValueError:
126
+ continue
127
+ if isinstance(doc, dict):
128
+ code = str(doc.get("code") or doc.get("error") or "")
129
+ reason = str(doc.get("reason") or doc.get("message") or "")
130
+ break
131
+ raise Refused(friendly(res.status, code, reason))
132
+ return RunStream(res.getheader(RUN_ID_HEADER), _iter_events(conn, res))
133
+
134
+
135
+ def _iter_events(conn: http.client.HTTPConnection, res: http.client.HTTPResponse) -> Iterator[dict[str, Any]]:
136
+ try:
137
+ while True:
138
+ line = res.readline(1_000_000)
139
+ if not line:
140
+ return
141
+ try:
142
+ doc = json.loads(line)
143
+ except ValueError:
144
+ continue
145
+ if isinstance(doc, dict) and isinstance(doc.get("event"), str):
146
+ yield doc
147
+ except (OSError, http.client.HTTPException) as err:
148
+ raise Unreachable() from err
149
+ finally:
150
+ conn.close()
151
+
152
+
153
+ def read_events(origin: str, bearer: str, session_id: str, run_id: str, seq: int) -> tuple[list[dict[str, Any]], int, str]:
154
+ """What the session's run has logged since this mark, and the next mark."""
155
+ query = urlencode({"sessionId": session_id, "runId": run_id, "seq": seq})
156
+ status, doc = _json_request(origin, "GET", f"/api/nx-cli/session-events?{query}", bearer)
157
+ if status == 401:
158
+ raise SignedOut()
159
+ if status >= 300:
160
+ raise Refused(friendly(status, str(doc.get("error") or ""), ""))
161
+ events = doc.get("events")
162
+ mark = doc.get("mark")
163
+ next_seq, next_run = seq, run_id
164
+ if isinstance(mark, dict) and isinstance(mark.get("seq"), int) and isinstance(mark.get("runId"), str):
165
+ next_seq, next_run = mark["seq"], mark["runId"]
166
+ return [e for e in events if isinstance(e, dict)] if isinstance(events, list) else [], next_seq, next_run
167
+
168
+
169
+ def stop_run(origin: str, bearer: str, session_id: str) -> bool:
170
+ status, doc = _json_request(origin, "POST", "/api/nx-cli/session-stop", bearer, {"sessionId": session_id})
171
+ if status == 401:
172
+ raise SignedOut()
173
+ return status < 300 and doc.get("ok") is True
174
+
175
+
176
+ def create_session(origin: str, bearer: str) -> str:
177
+ status, doc = _json_request(origin, "POST", "/api/nx-cli/sessions", bearer, {})
178
+ if status == 401:
179
+ raise SignedOut()
180
+ session_id = doc.get("sessionId")
181
+ if status >= 300 or not isinstance(session_id, str):
182
+ raise Refused(friendly(status, str(doc.get("error") or ""), str(doc.get("message") or "")))
183
+ return session_id
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class DeviceCode:
188
+ device_code: str
189
+ user_code: str
190
+ link: str
191
+ interval_s: float
192
+ expires_in_s: float
193
+
194
+
195
+ def start_device_grant(origin: str, version: str, device_name: str) -> DeviceCode:
196
+ status, doc = _json_request(origin, "POST", "/api/oauth/device/code", None, {"client_name": "NX CLI", "client_version": version, "device_name": device_name})
197
+ device_code, user_code, link = doc.get("device_code"), doc.get("user_code"), doc.get("verification_uri_complete")
198
+ if status >= 300 or not isinstance(device_code, str) or not isinstance(user_code, str) or not isinstance(link, str):
199
+ raise Refused(friendly(status, str(doc.get("error") or ""), ""))
200
+ return DeviceCode(device_code, user_code, link, float(doc.get("interval") or 5), float(doc.get("expires_in") or 900))
201
+
202
+
203
+ def poll_device_grant(origin: str, device_code: str) -> dict[str, Any] | str:
204
+ """The tokens, or "pending", "denied" or "expired"."""
205
+ status, doc = _json_request(origin, "POST", "/api/oauth/token", None, {"device_code": device_code})
206
+ if status < 300 and isinstance(doc.get("access_token"), str):
207
+ return doc
208
+ error = doc.get("error")
209
+ if error == "authorization_pending" or status >= 500:
210
+ return "pending"
211
+ return "denied" if error == "access_denied" else "expired"
212
+
213
+
214
+ def refresh_login(origin: str, refresh_token: str) -> tuple[str, str] | None:
215
+ status, doc = _json_request(origin, "POST", "/api/oauth/refresh", None, {"refresh_token": refresh_token})
216
+ access, refresh = doc.get("access_token"), doc.get("refresh_token")
217
+ return (access, refresh) if status < 300 and isinstance(access, str) and isinstance(refresh, str) else None
218
+
219
+
220
+ def revoke_device_session(origin: str, bearer: str, device_session_id: str) -> None:
221
+ _json_request(origin, "DELETE", f"/api/oauth/sessions/{device_session_id}", bearer)