repld-tool 0.2.2__tar.gz → 0.2.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 (63) hide show
  1. {repld_tool-0.2.2 → repld_tool-0.2.4}/PKG-INFO +1 -1
  2. {repld_tool-0.2.2 → repld_tool-0.2.4}/pyproject.toml +1 -1
  3. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/bridge.py +98 -1
  4. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/tab.py +39 -0
  5. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/help.py +13 -0
  6. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/kernel.py +31 -1
  7. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/paths.py +7 -0
  8. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/runtime.py +23 -7
  9. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/state.py +39 -0
  10. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/tasks.py +6 -0
  11. {repld_tool-0.2.2 → repld_tool-0.2.4}/LICENSE +0 -0
  12. {repld_tool-0.2.2 → repld_tool-0.2.4}/README.md +0 -0
  13. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/.ruff_cache/.gitignore +0 -0
  14. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
  15. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
  16. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/__init__.py +0 -0
  17. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/__main__.py +0 -0
  18. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/bg.py +0 -0
  19. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/bind.py +0 -0
  20. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/bridge_tools.py +0 -0
  21. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/__init__.py +0 -0
  22. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/browser.py +0 -0
  23. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/capture.py +0 -0
  24. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/cdp.py +0 -0
  25. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/har.py +0 -0
  26. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/observe.py +0 -0
  27. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/pin.py +0 -0
  28. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/png.py +0 -0
  29. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/pool.py +0 -0
  30. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/row.py +0 -0
  31. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/selector.py +0 -0
  32. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/session.py +0 -0
  33. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/tab_query.py +0 -0
  34. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/target.py +0 -0
  35. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser_dispatch.py +0 -0
  36. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/channel.py +0 -0
  37. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/cli.py +0 -0
  38. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/cli_args.py +0 -0
  39. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/core_schemas.py +0 -0
  40. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/dashboard.py +0 -0
  41. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/dashboard_cmd.py +0 -0
  42. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/dashboard_html.py +0 -0
  43. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/display.py +0 -0
  44. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/eventlog.py +0 -0
  45. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/events.py +0 -0
  46. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/exec_cmd.py +0 -0
  47. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gate_cmd.py +0 -0
  48. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gates.py +0 -0
  49. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_api.py +0 -0
  50. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_cmd.py +0 -0
  51. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_deps.py +0 -0
  52. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_links.py +0 -0
  53. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_lint.py +0 -0
  54. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gists.py +0 -0
  55. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/ipc.py +0 -0
  56. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/kernel_context.py +0 -0
  57. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/lifecycle_cmd.py +0 -0
  58. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/log_cmd.py +0 -0
  59. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/protocol.py +0 -0
  60. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/relaunch.py +0 -0
  61. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/render.py +0 -0
  62. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/sessions.py +0 -0
  63. {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/spawn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repld-tool
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Persistent Python runtime with MCP channel push. Dev shell and autonomous-agent substrate in one package.
5
5
  Keywords: repl,mcp,agent,browser
6
6
  Author: Fredrik Angelsen
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repld-tool"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Persistent Python runtime with MCP channel push. Dev shell and autonomous-agent substrate in one package."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -34,10 +34,12 @@ Two rules that fall out of running arbitrary user code:
34
34
  """
35
35
 
36
36
  import json
37
+ import os
37
38
  import socket
38
39
  import sys
39
40
  import threading
40
41
  import time
42
+ import uuid
41
43
  from pathlib import Path
42
44
 
43
45
  from . import (
@@ -83,6 +85,68 @@ BRIDGE_INIT_ID = "repld-bridge-init"
83
85
  # intercept declines exactly the gist and browser URIs, which do need one.
84
86
  _NEEDS_KERNEL = frozenset({"tools/call", "resources/read"})
85
87
 
88
+
89
+ def _ephemeral_socket_path() -> Path:
90
+ """A private, single-use socket path for `repld bridge --ephemeral`.
91
+
92
+ Deliberately outside PROJECTS_DIR: that tree is addressed by cwd
93
+ (`paths.project_dir`) precisely so a second bridge for the same project
94
+ attaches to the *same* kernel — the opposite of what --ephemeral
95
+ promises. A per-invocation subdirectory under RUNTIME_DIR, keyed on this
96
+ bridge's own pid plus a random suffix, guarantees no other bridge —
97
+ ephemeral or not — ever resolves to the same path, and the whole
98
+ directory is one `rmtree` for `_teardown_ephemeral` to remove on the way
99
+ out. That matters because neither `state.sweep_dead_pid_files` nor
100
+ `state.sweep_dead_project_dirs` would ever see it: the first only reaches
101
+ RUNTIME_DIR's flat `{pid}-…` files, the second only PROJECTS_DIR.
102
+
103
+ Two separate mkdir calls, not one `parents=True`: RUNTIME_DIR itself is
104
+ already 0700 (`ensure_runtime_dir`), but `mkdir(parents=True, mode=...)`
105
+ applies the mode only to the leaf it creates — the exact footgun
106
+ `ensure_runtime_dir`'s own docstring warns about — so an `ephemeral/`
107
+ parent brought into being implicitly would come out at umask default.
108
+ Each call here has an already-existing direct parent, so its `mode` is
109
+ the one that actually lands.
110
+ """
111
+ paths.ensure_runtime_dir()
112
+ root = paths.RUNTIME_DIR / "ephemeral"
113
+ root.mkdir(exist_ok=True, mode=0o700)
114
+ d = root / f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
115
+ d.mkdir(mode=0o700)
116
+ return d / "kernel.sock"
117
+
118
+
119
+ def _teardown_ephemeral(sock_path: Path, pid: int | None) -> None:
120
+ """Kill the one-off kernel `--ephemeral` spawned, then remove its directory.
121
+
122
+ SIGTERM first, so `_shutdown`'s `@every`/`defer()` drain gets its usual
123
+ 2s budget — the same courtesy `restart_kernel` extends the kernel it
124
+ replaces. SIGKILL only if it's still alive after `WAIT_STEPS` ticks: an
125
+ ephemeral kernel outliving the bridge that owns it is exactly the leak
126
+ this flag exists to prevent, so waiting indefinitely isn't an option the
127
+ way it is for a kernel meant to keep running for weeks.
128
+ """
129
+ import shutil
130
+ import signal
131
+
132
+ if pid is not None:
133
+ try:
134
+ os.kill(pid, signal.SIGTERM)
135
+ except OSError:
136
+ pid = None
137
+ else:
138
+ for _ in range(WAIT_STEPS):
139
+ if not state.pid_alive(pid):
140
+ break
141
+ time.sleep(WAIT_STEP_SECONDS)
142
+ else:
143
+ try:
144
+ os.kill(pid, signal.SIGKILL)
145
+ except OSError:
146
+ pass
147
+ shutil.rmtree(sock_path.parent, ignore_errors=True)
148
+
149
+
86
150
  _static_docs_cache: dict[str, str] | None = None
87
151
 
88
152
 
@@ -136,9 +200,13 @@ def _rid_of(line: str) -> object | None:
136
200
 
137
201
 
138
202
  class Bridge:
139
- def __init__(self, socket_path: Path) -> None:
203
+ def __init__(self, socket_path: Path, *, ephemeral: bool = False) -> None:
140
204
  self.socket_path = socket_path
141
205
  self.lock_path = paths.lock_for(socket_path)
206
+ # ephemeral spawns eagerly in run() rather than lazily on first real
207
+ # tool call, and tears the kernel down (_teardown_ephemeral) instead
208
+ # of leaving it running when stdin closes. See run_bridge's --ephemeral.
209
+ self._ephemeral = ephemeral
142
210
  self._sock: socket.socket | None = None
143
211
  self._kernel_pid: int | None = None
144
212
  self._generation = 0
@@ -718,6 +786,16 @@ class Bridge:
718
786
  # _try_bridge_intercept), and the first message that actually needs a
719
787
  # kernel — a real tools/call, a gist/browser resources/read — reaches
720
788
  # _ensure_kernel() in _handle_client_line and spawns one then.
789
+ #
790
+ # --ephemeral is the one exception, on purpose: its whole point is a
791
+ # live kernel for exactly this session, not a cache-backed discovery
792
+ # answer that defers spawning to the first real tool call. Spawning
793
+ # here also means every method — including `initialize` itself —
794
+ # forwards to a real kernel instead of the static/cached fallback,
795
+ # since _dispatch_client_line only takes that path while self._sock
796
+ # is still None.
797
+ if self._ephemeral:
798
+ self._ensure_kernel()
721
799
  try:
722
800
  for line in sys.stdin:
723
801
  if not line.endswith("\n"):
@@ -732,12 +810,15 @@ class Bridge:
732
810
  # those replies, and the reader thread writes them to stdout.
733
811
  self._drain_inflight()
734
812
  # The kernel keeps running — its state is meant to outlive the session.
813
+ # --ephemeral inverts that: it dies with the bridge that spawned it.
735
814
  sock = self._sock
736
815
  if sock is not None:
737
816
  try:
738
817
  sock.close()
739
818
  except OSError:
740
819
  pass
820
+ if self._ephemeral:
821
+ _teardown_ephemeral(self.socket_path, self._kernel_pid)
741
822
  return 0
742
823
 
743
824
 
@@ -745,6 +826,12 @@ _BRIDGE_USAGE = (
745
826
  "repld bridge — stdio MCP proxy (spawned by Claude Code, not run by hand)\n"
746
827
  "\n"
747
828
  " repld bridge [--socket PATH]\n"
829
+ " repld bridge --ephemeral\n"
830
+ "\n"
831
+ " --ephemeral spawns a private, single-use kernel for this bridge alone,\n"
832
+ " eagerly rather than on first tool call, and kills it when stdin closes\n"
833
+ " instead of leaving it running. Mutually exclusive with --socket: its\n"
834
+ " whole point is a path nothing else could already be attached to.\n"
748
835
  "\n"
749
836
  " Register with: claude mcp add repld -- repld bridge\n"
750
837
  )
@@ -760,6 +847,16 @@ def run_bridge(argv: list[str]) -> int:
760
847
  if cli_args.wants_help(argv):
761
848
  print(_BRIDGE_USAGE)
762
849
  return 0
850
+ if "--ephemeral" in argv:
851
+ rest = [a for a in argv if a != "--ephemeral"]
852
+ # No resolve_socket_path here: --ephemeral generates its own path and
853
+ # ignores REPLD_SOCKET too, so a bare --socket left in argv falls
854
+ # through to check_args as an unknown flag — which is exactly the
855
+ # mutual-exclusivity refusal, for free rather than as a special case.
856
+ bad = cli_args.check_args("repld bridge", rest, _BRIDGE_USAGE, positionals=0)
857
+ if bad is not None:
858
+ return bad
859
+ return Bridge(_ephemeral_socket_path(), ephemeral=True).run()
763
860
  socket_path, rest = paths.resolve_socket_path(argv)
764
861
  bad = cli_args.check_args("repld bridge", rest, _BRIDGE_USAGE, positionals=0)
765
862
  if bad is not None:
@@ -481,6 +481,40 @@ class Tab(TabQueryMixin):
481
481
  ) from reattach_exc
482
482
  return await self._session.execute(method, params, timeout)
483
483
 
484
+ async def _ensure_front(self) -> None:
485
+ """Raise this tab if Chrome has it backgrounded, before dispatching input.
486
+
487
+ `click`/`tap`/`type_text`/`key` all ride `Input.dispatch*Event` —
488
+ the renderer's real input pipeline, which Chrome silently drops for
489
+ a tab whose window is occluded or backgrounded
490
+ (`document.visibilityState === "hidden"`): the CDP command still
491
+ acks, no exception is raised, and no handler on the page ever
492
+ fires. Resolving a selector doesn't hit this — `DOM.getContentQuads`
493
+ and `Runtime.evaluate` both work fine on a hidden tab, which is why
494
+ the failure looks like nothing happened rather than an error. Costs
495
+ one `Runtime.evaluate` round trip on every call and `Page.bringToFront`
496
+ only on the degraded path — the multi-window watch workflow this
497
+ kernel exists for means N-1 tabs are hidden at any given time, so
498
+ this can't be opt-in.
499
+ """
500
+ result = await self._exec(
501
+ "Runtime.evaluate",
502
+ {"expression": "document.visibilityState", "returnByValue": True},
503
+ )
504
+ if result.get("result", {}).get("value") == "hidden":
505
+ await self._exec("Page.bringToFront")
506
+
507
+ async def front(self) -> None:
508
+ """Bring this tab's window to the front (Page.bringToFront).
509
+
510
+ click()/tap()/type_text()/key() already do this automatically when
511
+ the tab is backgrounded — call this directly to raise a tab for its
512
+ own sake (e.g. before a screenshot a human is watching for), or to
513
+ avoid paying the visibility check on every call in a script that
514
+ knows it's about to do a lot of input against one tab.
515
+ """
516
+ await self._exec("Page.bringToFront")
517
+
484
518
  # ------------------------------------------------------------------
485
519
 
486
520
  async def js(
@@ -643,6 +677,7 @@ class Tab(TabQueryMixin):
643
677
 
644
678
  Selector: CSS, text=Label, role=button[name='OK'], or tag:has-text('...')
645
679
  """
680
+ await self._ensure_front()
646
681
  x, y = await self._element_center(selector)
647
682
 
648
683
  for event_type in ("mousePressed", "mouseReleased"):
@@ -670,6 +705,7 @@ class Tab(TabQueryMixin):
670
705
  Selects all existing content then types over it.
671
706
  Selector: CSS, text=Label, role=textbox, label=Name, or tag:has-text('...')
672
707
  """
708
+ await self._ensure_front()
673
709
  node_id, js_expr = await self._wait_for_node(selector)
674
710
 
675
711
  if node_id:
@@ -712,6 +748,7 @@ class Tab(TabQueryMixin):
712
748
 
713
749
  async def key(self, key: str) -> None:
714
750
  """Dispatch a keyDown+keyUp pair for a named key (e.g. "Enter", "Escape")."""
751
+ await self._ensure_front()
715
752
  for event_type in ("keyDown", "keyUp"):
716
753
  await self._exec(
717
754
  "Input.dispatchKeyEvent",
@@ -735,6 +772,7 @@ class Tab(TabQueryMixin):
735
772
  Uses Input.dispatchTouchEvent — triggers touchstart/touchend listeners
736
773
  that dispatchMouseEvent won't reach. Use for mobile Chrome via ADB.
737
774
  """
775
+ await self._ensure_front()
738
776
  if y is not None:
739
777
  x, y = float(selector_or_x), y
740
778
  else:
@@ -759,6 +797,7 @@ class Tab(TabQueryMixin):
759
797
  Dispatches touchStart → touchMove × steps → touchEnd.
760
798
  For scrolling on mobile Chrome via ADB.
761
799
  """
800
+ await self._ensure_front()
762
801
  await self._touch("touchStart", [{"x": x1, "y": y1}])
763
802
  delay = duration_ms / steps / 1000
764
803
  for i in range(1, steps + 1):
@@ -482,6 +482,14 @@ Convention: add data-testid to your root layout component.
482
482
  3s timeout — raises TimeoutError if the page's touch handler blocks
483
483
  (common on complex apps like Messenger/React).
484
484
 
485
+ tab.front() → None
486
+ Bring this tab's window to the front (Page.bringToFront).
487
+ click/tap/type_text/key already call this automatically when Chrome
488
+ has the tab backgrounded — coordinate/key input silently does nothing
489
+ on a hidden tab otherwise (Chrome drops it, no error). Call directly
490
+ to raise a tab for its own sake, or to skip the per-call visibility
491
+ check in a script doing a lot of input against one tab.
492
+
485
493
  tab.swipe(x1, y1, x2, y2, *, steps=10, duration_ms=300) → None
486
494
  Touch swipe: touchStart → touchMove × steps → touchEnd.
487
495
  For scrolling on mobile Chrome via ADB.
@@ -1116,6 +1124,10 @@ defer(coro, label=None) → task_id
1116
1124
  coro can also be a zero-arg callable that builds the awaitable —
1117
1125
  defer(lambda: asyncio.gather(a(), b())) — so gather()/create_task()
1118
1126
  construct on the kernel's loop instead of a sync cell's own thread.
1127
+ A non-None return value is printed (into the push) and recoverable
1128
+ afterward as get_task(task_id)["result"] (a bounded repr — there's no
1129
+ cell number to bind _/_N to instead). no_display() suppresses the print
1130
+ but not the get_task recovery, same as it does for an exec cell.
1119
1131
 
1120
1132
  every(seconds, label=, delay=0)(fn) → fn periodic ticker; fn.cancel() stops
1121
1133
  delay= defers the first tick (default: tick now)
@@ -1152,6 +1164,7 @@ Tab (async unless noted):
1152
1164
  tab.tree() → list[str]
1153
1165
  tab.click(selector, button=, click_count=) → None (auto-waits 2s, mouse event)
1154
1166
  tab.tap(selector_or_x, y=) → None (touch event, 3s timeout)
1167
+ tab.front() → None (Page.bringToFront; click/tap/type_text/key auto-call this on a hidden tab)
1155
1168
  tab.swipe(x1, y1, x2, y2, steps=, duration_ms=) → None (touch scroll)
1156
1169
  tab.scroll(selector, dy=, dx=, steps=, duration_ms=) → None (touch-scroll container)
1157
1170
  tab.type_text(selector, text, delay_ms=, press_enter=) → None (clears first, auto-waits)
@@ -487,6 +487,21 @@ async def _run_cell(task_id: str, src: str, n: int, *, wait_ready: bool = True)
487
487
  task["exception"] = type(exc).__name__
488
488
 
489
489
 
490
+ _RESULT_REPR_LIMIT = 2000 # get_task's "result" field — a preview, not a payload
491
+
492
+
493
+ def _safe_repr(value: object) -> str:
494
+ """repr() a deferred task's result, bounded so a huge or unrepr-able value
495
+ can't take the task down (or blow up get_task's `json.dumps(snap)`)."""
496
+ try:
497
+ text = repr(value)
498
+ except Exception as exc:
499
+ return f"<repr() failed: {type(exc).__name__}>"
500
+ if len(text) > _RESULT_REPR_LIMIT:
501
+ text = text[:_RESULT_REPR_LIMIT] + f"… ({len(text)} chars total)"
502
+ return text
503
+
504
+
490
505
  async def _run_deferred(task_id: str, thunk) -> None:
491
506
  """Build and await a user-supplied awaitable within the task lifecycle.
492
507
 
@@ -500,7 +515,14 @@ async def _run_deferred(task_id: str, thunk) -> None:
500
515
  and a sync cell's body runs off-loop (`asyncio.to_thread`) to keep the
501
516
  kernel responsive — so building one directly in the cell raises "no
502
517
  current event loop" before defer() ever gets a look at it.
518
+
519
+ An `exec` cell's trailing expression gets `_`/`_N` plus a printed repr()
520
+ (`runtime.run_cell`); a deferred task has no cell number to bind, so it
521
+ gets the printed half instead (into the spill, and so into the
522
+ task_done push) plus a bounded repr in `task["result"]` for `get_task`.
503
523
  """
524
+ from . import runtime
525
+
504
526
  async with _task_scope(task_id) as task:
505
527
  try:
506
528
  awaitable = thunk()
@@ -509,7 +531,11 @@ async def _run_deferred(task_id: str, thunk) -> None:
509
531
  "defer() factory must return an awaitable, got "
510
532
  f"{type(awaitable).__name__}"
511
533
  )
512
- await awaitable
534
+ result, quiet = runtime.unwrap_display(await awaitable)
535
+ if result is not None:
536
+ task["result"] = _safe_repr(result)
537
+ if not quiet:
538
+ runtime.print_display(result)
513
539
  except asyncio.CancelledError:
514
540
  task["exception"] = "CancelledError"
515
541
  except BaseException as exc:
@@ -966,6 +992,10 @@ def _boot_runtime(sock_path: Path, display: bool) -> None:
966
992
  try:
967
993
  paths.ensure_runtime_dir()
968
994
  state.sweep_dead_pid_files(paths.RUNTIME_DIR)
995
+ # 2a-ii. Same idea, one level up: reclaim a dead kernel's whole
996
+ # PROJECTS_DIR/<slug>/ directory, not just the per-process scratch
997
+ # above — see state.sweep_dead_project_dirs' docstring.
998
+ state.sweep_dead_project_dirs(paths.PROJECTS_DIR)
969
999
  except Exception as e:
970
1000
  print(f"repld: could not sweep stale runtime files: {e}", file=sys.stderr)
971
1001
 
@@ -32,6 +32,13 @@ driven by ``tasks._sweep_orphans``), which is the half boot-time sweeping can
32
32
  never cover for a process that stays up for weeks. Keep the prefix on anything
33
33
  new written here or neither rule will find it.
34
34
 
35
+ A dead kernel's ``PROJECTS_DIR/<slug>/`` directory itself is a third case
36
+ those two rules don't reach — they sweep files sitting flat in RUNTIME_DIR,
37
+ never the per-project subdirectories one level up. ``state.sweep_dead_project_dirs``
38
+ is that third rule: same boot-time pass, same liveness check via
39
+ ``state.read_lock``, applied to ``kernel.lock`` instead of a ``{pid}-…``
40
+ filename.
41
+
35
42
  Without ``XDG_RUNTIME_DIR`` (macOS, most containers, any env-scrubbed spawn)
36
43
  the tree falls back to ``/tmp/repld-{uid}``. The uid suffix is load-bearing:
37
44
  ``/tmp`` is world-writable, so a shared unsuffixed name could be pre-created
@@ -48,6 +48,27 @@ def no_display(value: Any) -> Any:
48
48
  return _NoDisplay(value)
49
49
 
50
50
 
51
+ def unwrap_display(result: Any) -> tuple[Any, bool]:
52
+ """Unwrap a `no_display()` wrapper, if `result` is one. Returns (value, quiet).
53
+
54
+ The one sanctioned way to inspect `_NoDisplay` from outside this module —
55
+ `defer()`'s deferred tasks have the same suppress-print-but-not-recovery
56
+ need as a cell's trailing expression, with no cell number to route
57
+ through `run_cell` for.
58
+ """
59
+ if isinstance(result, _NoDisplay):
60
+ return result.value, True
61
+ return result, False
62
+
63
+
64
+ def print_display(result: Any) -> None:
65
+ """Print `result` the way a cell's auto-displayed trailing expression does."""
66
+ if isinstance(result, str) and "\n" in result:
67
+ print(result)
68
+ else:
69
+ print(repr(result))
70
+
71
+
51
72
  def _assign_target_names(stmts: list) -> set:
52
73
  """Collect top-level simple-assignment target names from a statement list.
53
74
 
@@ -157,14 +178,9 @@ async def run_cell(compiled: tuple, ns: dict, n: int) -> Any:
157
178
  await _eval(code, ns)
158
179
  _unwrap_assigned(ns, names)
159
180
  if tag in ("eval", "exec_eval") and result is not None:
160
- quiet = isinstance(result, _NoDisplay)
161
- if quiet:
162
- result = result.value
181
+ result, quiet = unwrap_display(result)
163
182
  if not quiet:
164
- if isinstance(result, str) and "\n" in result:
165
- print(result)
166
- else:
167
- print(repr(result))
183
+ print_display(result)
168
184
  # Shift history: _ → __, __ → ___. Matches IPython convention.
169
185
  ns["___"] = ns.get("__")
170
186
  ns["__"] = ns.get("_")
@@ -17,6 +17,7 @@ import io
17
17
  import json
18
18
  import os
19
19
  import re
20
+ import shutil
20
21
  import time
21
22
  from pathlib import Path
22
23
  from typing import IO, Any, Literal, overload
@@ -243,6 +244,44 @@ def sweep_own_stale_files(
243
244
  return removed
244
245
 
245
246
 
247
+ def sweep_dead_project_dirs(projects_dir: Path) -> int:
248
+ """Reclaim a dead kernel's whole per-project directory under PROJECTS_DIR.
249
+
250
+ The third boot-time sweep rule described in paths.py's module docstring —
251
+ applied one level up from `sweep_dead_pid_files`, to the
252
+ ``PROJECTS_DIR/<slug>/`` directory itself rather than files inside it.
253
+
254
+ Only reclaims a directory whose ``kernel.lock`` exists *and* names a dead
255
+ pid — the same asymmetry `sweep_dead_pid_files` uses, in the same
256
+ direction. A directory with no lock yet may be another kernel mid-boot
257
+ (flock taken in `_claim_project`, lock not written until
258
+ `_write_lockfile` much later in `_start_services`); reclaiming it would
259
+ race that boot. A directory with no lock *ever* (a client resolved
260
+ `project_dir()` — `repld status`, `repld exec` — but no kernel started
261
+ there) is left alone too: there's nothing here to say it's abandoned
262
+ rather than simply unused, and it costs nothing to leave standing.
263
+ """
264
+ removed = 0
265
+ try:
266
+ entries = list(projects_dir.iterdir())
267
+ except OSError:
268
+ return 0
269
+ for entry in entries:
270
+ if not entry.is_dir():
271
+ continue
272
+ lock_path = entry / "kernel.lock"
273
+ if not lock_path.exists():
274
+ continue
275
+ if isinstance(read_lock(lock_path), dict):
276
+ continue # pid alive
277
+ try:
278
+ shutil.rmtree(entry)
279
+ except OSError:
280
+ continue
281
+ removed += 1
282
+ return removed
283
+
284
+
246
285
  def acquire_lock(flock_path: Path) -> int | None:
247
286
  """Take the single-kernel-per-project mutex.
248
287
 
@@ -135,6 +135,11 @@ def new_task(origin: object = None) -> tuple[str, dict]:
135
135
  task: dict = {
136
136
  "done_event": threading.Event(),
137
137
  "exception": None,
138
+ # Bounded-repr'd, never the raw object: this dict feeds straight into
139
+ # get_task's `json.dumps(snap)`, and an arbitrary result (a DataFrame,
140
+ # a custom class) would break that RPC. Set only by `_run_deferred`;
141
+ # an `exec` cell's result goes to `_`/`_N` instead.
142
+ "result": None,
138
143
  "spill_file": None,
139
144
  "spill_path": None,
140
145
  "nudged": False,
@@ -284,6 +289,7 @@ def snapshot(task_id: str) -> dict | None:
284
289
  "spilled": task["spill_path"] is not None,
285
290
  "spill_path": task["spill_path"],
286
291
  "exception": task["exception"],
292
+ "result": task.get("result"),
287
293
  "done": task["done_event"].is_set(),
288
294
  "label": task.get("label"),
289
295
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes