dulus 0.2.30__tar.gz → 0.2.32__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 (164) hide show
  1. {dulus-0.2.30/dulus.egg-info → dulus-0.2.32}/PKG-INFO +1 -1
  2. {dulus-0.2.30 → dulus-0.2.32/dulus.egg-info}/PKG-INFO +1 -1
  3. {dulus-0.2.30 → dulus-0.2.32}/dulus.py +223 -48
  4. {dulus-0.2.30 → dulus-0.2.32}/pyproject.toml +1 -1
  5. {dulus-0.2.30 → dulus-0.2.32}/LICENSE +0 -0
  6. {dulus-0.2.30 → dulus-0.2.32}/MANIFEST.in +0 -0
  7. {dulus-0.2.30 → dulus-0.2.32}/README.md +0 -0
  8. {dulus-0.2.30 → dulus-0.2.32}/agent.py +0 -0
  9. {dulus-0.2.30 → dulus-0.2.32}/backend/__init__.py +0 -0
  10. {dulus-0.2.30 → dulus-0.2.32}/backend/compressor.py +0 -0
  11. {dulus-0.2.30 → dulus-0.2.32}/backend/context.py +0 -0
  12. {dulus-0.2.30 → dulus-0.2.32}/backend/githook.py +0 -0
  13. {dulus-0.2.30 → dulus-0.2.32}/backend/marketplace.py +0 -0
  14. {dulus-0.2.30 → dulus-0.2.32}/backend/mempalace_bridge.py +0 -0
  15. {dulus-0.2.30 → dulus-0.2.32}/backend/personas.py +0 -0
  16. {dulus-0.2.30 → dulus-0.2.32}/backend/plugins.py +0 -0
  17. {dulus-0.2.30 → dulus-0.2.32}/backend/server.py +0 -0
  18. {dulus-0.2.30 → dulus-0.2.32}/backend/tasks.py +0 -0
  19. {dulus-0.2.30 → dulus-0.2.32}/batch_api.py +0 -0
  20. {dulus-0.2.30 → dulus-0.2.32}/checkpoint/__init__.py +0 -0
  21. {dulus-0.2.30 → dulus-0.2.32}/checkpoint/hooks.py +0 -0
  22. {dulus-0.2.30 → dulus-0.2.32}/checkpoint/store.py +0 -0
  23. {dulus-0.2.30 → dulus-0.2.32}/checkpoint/types.py +0 -0
  24. {dulus-0.2.30 → dulus-0.2.32}/claude_code_watcher.py +0 -0
  25. {dulus-0.2.30 → dulus-0.2.32}/clipboard_utils.py +0 -0
  26. {dulus-0.2.30 → dulus-0.2.32}/cloudsave.py +0 -0
  27. {dulus-0.2.30 → dulus-0.2.32}/common.py +0 -0
  28. {dulus-0.2.30 → dulus-0.2.32}/compaction.py +0 -0
  29. {dulus-0.2.30 → dulus-0.2.32}/config.py +0 -0
  30. {dulus-0.2.30 → dulus-0.2.32}/context.py +0 -0
  31. {dulus-0.2.30 → dulus-0.2.32}/data/__init__.py +0 -0
  32. {dulus-0.2.30 → dulus-0.2.32}/data/active_persona.json +0 -0
  33. {dulus-0.2.30 → dulus-0.2.32}/data/context.json +0 -0
  34. {dulus-0.2.30 → dulus-0.2.32}/data/marketplace.json +0 -0
  35. {dulus-0.2.30 → dulus-0.2.32}/data/personas.json +0 -0
  36. {dulus-0.2.30 → dulus-0.2.32}/data/plugins/__init__.py +0 -0
  37. {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/__init__.py +0 -0
  38. {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/composio_plugin/__init__.py +0 -0
  39. {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/composio_plugin/session_manager.py +0 -0
  40. {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/composio_plugin/tool_generator.py +0 -0
  41. {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/plugin.json +0 -0
  42. {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/plugin_tool.py +0 -0
  43. {dulus-0.2.30 → dulus-0.2.32}/data/tasks.json +0 -0
  44. {dulus-0.2.30 → dulus-0.2.32}/docs/README.md +0 -0
  45. {dulus-0.2.30 → dulus-0.2.32}/docs/__init__.py +0 -0
  46. {dulus-0.2.30 → dulus-0.2.32}/docs/api.html +0 -0
  47. {dulus-0.2.30 → dulus-0.2.32}/docs/architecture.md +0 -0
  48. {dulus-0.2.30 → dulus-0.2.32}/docs/azure-speech-template.json +0 -0
  49. {dulus-0.2.30 → dulus-0.2.32}/docs/dashboard/index.html +0 -0
  50. {dulus-0.2.30 → dulus-0.2.32}/docs/divider.svg +0 -0
  51. {dulus-0.2.30 → dulus-0.2.32}/docs/generate.py +0 -0
  52. {dulus-0.2.30 → dulus-0.2.32}/docs/hero.svg +0 -0
  53. {dulus-0.2.30 → dulus-0.2.32}/docs/index.html +0 -0
  54. {dulus-0.2.30 → dulus-0.2.32}/docs/news.md +0 -0
  55. {dulus-0.2.30 → dulus-0.2.32}/docs/nvidia-models.svg +0 -0
  56. {dulus-0.2.30 → dulus-0.2.32}/docs/particle-playground.html +0 -0
  57. {dulus-0.2.30 → dulus-0.2.32}/docs/personas/index.html +0 -0
  58. {dulus-0.2.30 → dulus-0.2.32}/docs/poetry-banner.png +0 -0
  59. {dulus-0.2.30 → dulus-0.2.32}/docs/preview.html +0 -0
  60. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-agents.svg +0 -0
  61. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-brainstorm.svg +0 -0
  62. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-bridges.svg +0 -0
  63. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-features.svg +0 -0
  64. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-freetier.svg +0 -0
  65. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-memory.svg +0 -0
  66. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-models.svg +0 -0
  67. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-perms.svg +0 -0
  68. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-plugins.svg +0 -0
  69. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-quickstart.svg +0 -0
  70. {dulus-0.2.30 → dulus-0.2.32}/docs/sec-ssj.svg +0 -0
  71. {dulus-0.2.30 → dulus-0.2.32}/docs/spinners.svg +0 -0
  72. {dulus-0.2.30 → dulus-0.2.32}/docs/split-pane.svg +0 -0
  73. {dulus-0.2.30 → dulus-0.2.32}/docs/terminal-boot.svg +0 -0
  74. {dulus-0.2.30 → dulus-0.2.32}/docs/uploads/particle-playground.html +0 -0
  75. {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/SOURCES.txt +0 -0
  76. {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/dependency_links.txt +0 -0
  77. {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/entry_points.txt +0 -0
  78. {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/requires.txt +0 -0
  79. {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/top_level.txt +0 -0
  80. {dulus-0.2.30 → dulus-0.2.32}/dulus_gui.py +0 -0
  81. {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/__init__.py +0 -0
  82. {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/client.py +0 -0
  83. {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/config.py +0 -0
  84. {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/tools.py +0 -0
  85. {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/types.py +0 -0
  86. {dulus-0.2.30 → dulus-0.2.32}/gui/__init__.py +0 -0
  87. {dulus-0.2.30 → dulus-0.2.32}/gui/agent_bridge.py +0 -0
  88. {dulus-0.2.30 → dulus-0.2.32}/gui/chat_widget.py +0 -0
  89. {dulus-0.2.30 → dulus-0.2.32}/gui/main_window.py +0 -0
  90. {dulus-0.2.30 → dulus-0.2.32}/gui/personas.py +0 -0
  91. {dulus-0.2.30 → dulus-0.2.32}/gui/session_utils.py +0 -0
  92. {dulus-0.2.30 → dulus-0.2.32}/gui/settings_dialog.py +0 -0
  93. {dulus-0.2.30 → dulus-0.2.32}/gui/sidebar.py +0 -0
  94. {dulus-0.2.30 → dulus-0.2.32}/gui/tasks_view.py +0 -0
  95. {dulus-0.2.30 → dulus-0.2.32}/gui/themes.py +0 -0
  96. {dulus-0.2.30 → dulus-0.2.32}/gui/tool_panel.py +0 -0
  97. {dulus-0.2.30 → dulus-0.2.32}/input.py +0 -0
  98. {dulus-0.2.30 → dulus-0.2.32}/license_manager.py +0 -0
  99. {dulus-0.2.30 → dulus-0.2.32}/memory/__init__.py +0 -0
  100. {dulus-0.2.30 → dulus-0.2.32}/memory/audit.py +0 -0
  101. {dulus-0.2.30 → dulus-0.2.32}/memory/consolidator.py +0 -0
  102. {dulus-0.2.30 → dulus-0.2.32}/memory/context.py +0 -0
  103. {dulus-0.2.30 → dulus-0.2.32}/memory/offload.py +0 -0
  104. {dulus-0.2.30 → dulus-0.2.32}/memory/palace.py +0 -0
  105. {dulus-0.2.30 → dulus-0.2.32}/memory/scan.py +0 -0
  106. {dulus-0.2.30 → dulus-0.2.32}/memory/sessions.py +0 -0
  107. {dulus-0.2.30 → dulus-0.2.32}/memory/store.py +0 -0
  108. {dulus-0.2.30 → dulus-0.2.32}/memory/tools.py +0 -0
  109. {dulus-0.2.30 → dulus-0.2.32}/memory/types.py +0 -0
  110. {dulus-0.2.30 → dulus-0.2.32}/memory/vector_search.py +0 -0
  111. {dulus-0.2.30 → dulus-0.2.32}/multi_agent/__init__.py +0 -0
  112. {dulus-0.2.30 → dulus-0.2.32}/multi_agent/subagent.py +0 -0
  113. {dulus-0.2.30 → dulus-0.2.32}/multi_agent/tools.py +0 -0
  114. {dulus-0.2.30 → dulus-0.2.32}/offload_helper.py +0 -0
  115. {dulus-0.2.30 → dulus-0.2.32}/plugin/__init__.py +0 -0
  116. {dulus-0.2.30 → dulus-0.2.32}/plugin/autoadapter.py +0 -0
  117. {dulus-0.2.30 → dulus-0.2.32}/plugin/loader.py +0 -0
  118. {dulus-0.2.30 → dulus-0.2.32}/plugin/recommend.py +0 -0
  119. {dulus-0.2.30 → dulus-0.2.32}/plugin/store.py +0 -0
  120. {dulus-0.2.30 → dulus-0.2.32}/plugin/types.py +0 -0
  121. {dulus-0.2.30 → dulus-0.2.32}/providers.py +0 -0
  122. {dulus-0.2.30 → dulus-0.2.32}/setup.cfg +0 -0
  123. {dulus-0.2.30 → dulus-0.2.32}/skill/__init__.py +0 -0
  124. {dulus-0.2.30 → dulus-0.2.32}/skill/builtin.py +0 -0
  125. {dulus-0.2.30 → dulus-0.2.32}/skill/clawhub.py +0 -0
  126. {dulus-0.2.30 → dulus-0.2.32}/skill/executor.py +0 -0
  127. {dulus-0.2.30 → dulus-0.2.32}/skill/loader.py +0 -0
  128. {dulus-0.2.30 → dulus-0.2.32}/skill/tools.py +0 -0
  129. {dulus-0.2.30 → dulus-0.2.32}/skills.py +0 -0
  130. {dulus-0.2.30 → dulus-0.2.32}/spinner.py +0 -0
  131. {dulus-0.2.30 → dulus-0.2.32}/string_utils.py +0 -0
  132. {dulus-0.2.30 → dulus-0.2.32}/subagent.py +0 -0
  133. {dulus-0.2.30 → dulus-0.2.32}/task/__init__.py +0 -0
  134. {dulus-0.2.30 → dulus-0.2.32}/task/store.py +0 -0
  135. {dulus-0.2.30 → dulus-0.2.32}/task/tools.py +0 -0
  136. {dulus-0.2.30 → dulus-0.2.32}/task/types.py +0 -0
  137. {dulus-0.2.30 → dulus-0.2.32}/tests/test_checkpoint.py +0 -0
  138. {dulus-0.2.30 → dulus-0.2.32}/tests/test_compaction.py +0 -0
  139. {dulus-0.2.30 → dulus-0.2.32}/tests/test_diff_view.py +0 -0
  140. {dulus-0.2.30 → dulus-0.2.32}/tests/test_injection_fix.py +0 -0
  141. {dulus-0.2.30 → dulus-0.2.32}/tests/test_license.py +0 -0
  142. {dulus-0.2.30 → dulus-0.2.32}/tests/test_mcp.py +0 -0
  143. {dulus-0.2.30 → dulus-0.2.32}/tests/test_memory.py +0 -0
  144. {dulus-0.2.30 → dulus-0.2.32}/tests/test_plugin.py +0 -0
  145. {dulus-0.2.30 → dulus-0.2.32}/tests/test_skills.py +0 -0
  146. {dulus-0.2.30 → dulus-0.2.32}/tests/test_subagent.py +0 -0
  147. {dulus-0.2.30 → dulus-0.2.32}/tests/test_task.py +0 -0
  148. {dulus-0.2.30 → dulus-0.2.32}/tests/test_telegram_buffer.py +0 -0
  149. {dulus-0.2.30 → dulus-0.2.32}/tests/test_tool_registry.py +0 -0
  150. {dulus-0.2.30 → dulus-0.2.32}/tests/test_voice.py +0 -0
  151. {dulus-0.2.30 → dulus-0.2.32}/tmux_offloader.py +0 -0
  152. {dulus-0.2.30 → dulus-0.2.32}/tmux_tools.py +0 -0
  153. {dulus-0.2.30 → dulus-0.2.32}/tool_registry.py +0 -0
  154. {dulus-0.2.30 → dulus-0.2.32}/tools.py +0 -0
  155. {dulus-0.2.30 → dulus-0.2.32}/ui/__init__.py +0 -0
  156. {dulus-0.2.30 → dulus-0.2.32}/ui/input.py +0 -0
  157. {dulus-0.2.30 → dulus-0.2.32}/ui/render.py +0 -0
  158. {dulus-0.2.30 → dulus-0.2.32}/voice/__init__.py +0 -0
  159. {dulus-0.2.30 → dulus-0.2.32}/voice/keyterms.py +0 -0
  160. {dulus-0.2.30 → dulus-0.2.32}/voice/recorder.py +0 -0
  161. {dulus-0.2.30 → dulus-0.2.32}/voice/stt.py +0 -0
  162. {dulus-0.2.30 → dulus-0.2.32}/voice/tts.py +0 -0
  163. {dulus-0.2.30 → dulus-0.2.32}/webchat.py +0 -0
  164. {dulus-0.2.30 → dulus-0.2.32}/webchat_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dulus
3
- Version: 0.2.30
3
+ Version: 0.2.32
4
4
  Summary: Spanish-first multi-provider AI CLI — 14 NVIDIA models free, Mesa Redonda, voice, TTS, RTK token reducer, MemPalace
5
5
  Author: KevRojo
6
6
  License: GPL-3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dulus
3
- Version: 0.2.30
3
+ Version: 0.2.32
4
4
  Summary: Spanish-first multi-provider AI CLI — 14 NVIDIA models free, Mesa Redonda, voice, TTS, RTK token reducer, MemPalace
5
5
  Author: KevRojo
6
6
  License: GPL-3.0
@@ -1563,16 +1563,32 @@ def cmd_bg(args: str, _state, config) -> bool:
1563
1563
  def _is_alive(pid: int) -> bool:
1564
1564
  if pid <= 0:
1565
1565
  return False
1566
- try:
1567
- if _sys.platform == "win32":
1568
- # On Windows, os.kill(pid, 0) raises if the process doesn't exist.
1569
- _os.kill(pid, 0)
1566
+ if _sys.platform == "win32":
1567
+ # os.kill(pid, 0) on Windows is unreliable for GUI-subsystem
1568
+ # processes (pythonw.exe): it raises OSError(errno=22) even
1569
+ # when the process is alive. Use the native OpenProcess API.
1570
+ try:
1571
+ import ctypes
1572
+ kernel32 = ctypes.windll.kernel32
1573
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
1574
+ h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
1575
+ if h:
1576
+ kernel32.CloseHandle(h)
1577
+ return True
1578
+ # OpenProcess returned 0 — check last error
1579
+ err = kernel32.GetLastError()
1580
+ # ERROR_INVALID_PARAMETER (87) = PID does not exist
1581
+ return err != 87
1582
+ except Exception:
1583
+ # Fallback: if the native API fails, assume alive so we
1584
+ # still attempt taskkill downstream.
1570
1585
  return True
1571
- else:
1586
+ else:
1587
+ try:
1572
1588
  _os.kill(pid, 0)
1573
1589
  return True
1574
- except (ProcessLookupError, OSError, PermissionError):
1575
- return False
1590
+ except (ProcessLookupError, OSError):
1591
+ return False
1576
1592
 
1577
1593
  def _read_pid() -> int:
1578
1594
  try:
@@ -1634,19 +1650,34 @@ def cmd_bg(args: str, _state, config) -> bool:
1634
1650
  except FileNotFoundError:
1635
1651
  pass
1636
1652
  return True
1653
+ sigterm_ok = False
1637
1654
  try:
1655
+ _os.kill(pid, _signal.SIGTERM)
1656
+ sigterm_ok = True
1657
+ ok(f"Sent SIGTERM to PID {pid}.")
1658
+ except PermissionError:
1659
+ # On Windows os.kill() to a GUI-subsystem process (pythonw.exe)
1660
+ # often raises PermissionError. Escalate to taskkill immediately.
1638
1661
  if _sys.platform == "win32":
1639
- _os.kill(pid, _signal.SIGTERM)
1662
+ try:
1663
+ _sp.run(["taskkill", "/F", "/PID", str(pid)],
1664
+ capture_output=True, timeout=5)
1665
+ ok(f"Forced stop via taskkill /F on PID {pid}.")
1666
+ except Exception as tk_e:
1667
+ err(f"Failed to kill {pid}: {tk_e}")
1668
+ return True
1640
1669
  else:
1641
- _os.kill(pid, _signal.SIGTERM)
1642
- ok(f"Sent SIGTERM to PID {pid}.")
1670
+ err(f"Failed to kill {pid}: Permission denied")
1671
+ return True
1643
1672
  except Exception as e:
1644
1673
  err(f"Failed to kill {pid}: {e}")
1645
1674
  return True
1646
- for _ in range(20):
1647
- if not _is_alive(pid):
1648
- break
1649
- _time.sleep(0.25)
1675
+
1676
+ if sigterm_ok:
1677
+ for _ in range(20):
1678
+ if not _is_alive(pid):
1679
+ break
1680
+ _time.sleep(0.25)
1650
1681
  try:
1651
1682
  BG_PID.unlink()
1652
1683
  except FileNotFoundError:
@@ -1657,32 +1688,106 @@ def cmd_bg(args: str, _state, config) -> bool:
1657
1688
  # ── /bg attach ────────────────────────────────────────────────────────
1658
1689
  if sub == "attach":
1659
1690
  pid = _read_pid()
1660
- if not _is_alive(pid):
1661
- warn("Dulus background not running. Use `/bg start` first.")
1691
+ if not _is_alive(pid) or not _ipc_alive():
1692
+ warn("No background daemon running. Use `/bg start` first.")
1662
1693
  return True
1663
- port = config.get("_webchat_port", 5000)
1664
- info("Attach options:")
1665
- info(f" From any shell: dulus \"your prompt\" (joins via IPC)")
1666
- info(f" Browser: http://127.0.0.1:{port}/")
1667
- if _sys.platform != "win32":
1668
- info(f" • Tmux (if used): tmux attach -t dulus-bg")
1669
- info(f" • Tail log: tail -f {BG_LOG}")
1694
+ # Enter a mini-REPL that dispatches to the daemon via IPC
1695
+ ok("Attached to background daemon. Type your prompts (Ctrl+C to detach).")
1696
+ info(f" PID: {pid} | IPC: 127.0.0.1:{DULUS_IPC_PORT}")
1697
+ info(f" Web: http://127.0.0.1:{config.get('_webchat_port', 5000)}/")
1698
+ info(" /exit or /detach to disconnect.")
1699
+ while True:
1700
+ try:
1701
+ line = input(clr(" bg> ", "cyan"))
1702
+ except (KeyboardInterrupt, EOFError):
1703
+ print()
1704
+ info("Detached.")
1705
+ break
1706
+ line = line.strip()
1707
+ if not line:
1708
+ continue
1709
+ if line.lower() in ("/exit", "/detach", "/quit"):
1710
+ info("Detached.")
1711
+ break
1712
+ # Send to daemon via IPC
1713
+ try:
1714
+ s = _socket.create_connection(("127.0.0.1", DULUS_IPC_PORT), timeout=5)
1715
+ s.sendall(_json.dumps({"prompt": line}).encode() + b"\n")
1716
+ s.settimeout(300)
1717
+ buf = b""
1718
+ while b"\n" not in buf:
1719
+ chunk = s.recv(4096)
1720
+ if not chunk:
1721
+ break
1722
+ buf += chunk
1723
+ s.close()
1724
+ resp = _json.loads(buf.split(b"\n")[0])
1725
+ reply = resp.get("response", resp.get("error", "(no response)"))
1726
+ print(reply)
1727
+ except Exception as e:
1728
+ err(f"IPC error: {e}")
1670
1729
  return True
1671
1730
 
1672
1731
  # ── /bg kill ──────────────────────────────────────────────────────────
1673
- # Force-stop the background DAEMON only — never the user's own REPL.
1674
- # We use the BG_PID file as the source of truth: only /bg start writes
1675
- # it, so it uniquely identifies the detached daemon. If no BG_PID file
1676
- # exists, we refuse to kill anything (port may be held by a REPL the
1677
- # user wants to keep). For SIGKILL escalation we use taskkill on Windows.
1732
+ # Force-stop whatever is holding the IPC port.
1733
+ # Priority 1: BG_PID file (fastest, most reliable).
1734
+ # Priority 2: discover the PID from the OS by scanning port 5151.
1735
+ # We NEVER kill our own REPL process (own_pid check).
1736
+ # For SIGKILL escalation we use taskkill on Windows.
1678
1737
  if sub == "kill":
1679
1738
  f_pid = _read_pid()
1680
1739
  own_pid = _os.getpid()
1681
1740
 
1741
+ def _discover_pid_from_port(port: int) -> int:
1742
+ """Ask the OS which process owns the given TCP port."""
1743
+ try:
1744
+ if _sys.platform == "win32":
1745
+ # netstat -ano → find the line with :5151 in LISTENING state
1746
+ result = _sp.run(
1747
+ ["netstat", "-ano"],
1748
+ capture_output=True, text=True, timeout=5
1749
+ )
1750
+ for line in result.stdout.splitlines():
1751
+ if f":{port}" in line and ("LISTENING" in line or "ESTABLISHED" in line):
1752
+ parts = line.strip().split()
1753
+ if parts:
1754
+ try:
1755
+ return int(parts[-1])
1756
+ except ValueError:
1757
+ continue
1758
+ else:
1759
+ # lsof -ti :port (outputs PID only)
1760
+ result = _sp.run(
1761
+ ["lsof", "-ti", f":{port}"],
1762
+ capture_output=True, text=True, timeout=5
1763
+ )
1764
+ if result.stdout.strip():
1765
+ return int(result.stdout.strip().splitlines()[0])
1766
+ # Fallback to fuser
1767
+ result = _sp.run(
1768
+ ["fuser", f"{port}/tcp"],
1769
+ capture_output=True, text=True, timeout=5
1770
+ )
1771
+ if result.stdout.strip():
1772
+ parts = result.stdout.strip().split(":")
1773
+ if len(parts) > 1:
1774
+ return int(parts[1].strip().split()[0])
1775
+ except Exception:
1776
+ pass
1777
+ return 0
1778
+
1779
+ # No PID file? Discover from the OS if the port is in use.
1780
+ if not f_pid and _ipc_alive():
1781
+ discovered = _discover_pid_from_port(DULUS_IPC_PORT)
1782
+ if discovered and discovered != own_pid:
1783
+ f_pid = discovered
1784
+ info(f"No PID file — discovered process {f_pid} holding port {DULUS_IPC_PORT}.")
1785
+ elif discovered == own_pid:
1786
+ warn("Port is held by this REPL — close it with /exit instead.")
1787
+ return True
1788
+
1682
1789
  if not f_pid:
1683
- info("No background daemon to kill (BG_PID file missing).")
1684
- info(f" If port {DULUS_IPC_PORT} is in use, it's likely your own REPL.")
1685
- info(" Close your REPL (/exit) to free the port — /bg kill won't touch it.")
1790
+ info("No background daemon to kill (port free, PID file missing).")
1686
1791
  return True
1687
1792
 
1688
1793
  if f_pid == own_pid:
@@ -1702,29 +1807,48 @@ def cmd_bg(args: str, _state, config) -> bool:
1702
1807
  return True
1703
1808
 
1704
1809
  # Send SIGTERM, give it 1s, escalate to SIGKILL/taskkill if it lingers.
1810
+ # On Windows, os.kill() to a GUI-subsystem process (pythonw.exe) often
1811
+ # raises PermissionError. We catch that immediately and escalate to
1812
+ # taskkill /F instead of giving up.
1813
+ sigterm_ok = False
1705
1814
  try:
1706
1815
  _os.kill(f_pid, _signal.SIGTERM)
1816
+ sigterm_ok = True
1707
1817
  ok(f"Sent SIGTERM to daemon PID {f_pid}.")
1708
- except (ProcessLookupError, PermissionError, OSError) as e:
1818
+ except PermissionError:
1819
+ if _sys.platform == "win32":
1820
+ warn(f"Permission denied signalling PID {f_pid} — escalating to taskkill /F.")
1821
+ try:
1822
+ _sp.run(["taskkill", "/F", "/PID", str(f_pid)],
1823
+ capture_output=True, timeout=5)
1824
+ ok(f"Forced kill via taskkill /F on PID {f_pid}.")
1825
+ except Exception as tk_e:
1826
+ err(f"taskkill failed: {tk_e}")
1827
+ return True
1828
+ else:
1829
+ err(f"Could not signal PID {f_pid}: Permission denied")
1830
+ return True
1831
+ except (ProcessLookupError, OSError) as e:
1709
1832
  err(f"Could not signal PID {f_pid}: {e}")
1710
1833
  return True
1711
1834
 
1712
- for _ in range(8):
1713
- if not _is_alive(f_pid):
1714
- break
1715
- _time.sleep(0.25)
1835
+ if sigterm_ok:
1836
+ for _ in range(8):
1837
+ if not _is_alive(f_pid):
1838
+ break
1839
+ _time.sleep(0.25)
1716
1840
 
1717
- if _is_alive(f_pid):
1718
- warn(f"PID {f_pid} did not exit on SIGTERM — escalating to SIGKILL.")
1719
- try:
1720
- if _sys.platform == "win32":
1721
- _sp.run(["taskkill", "/F", "/PID", str(f_pid)],
1722
- capture_output=True, timeout=5)
1723
- else:
1724
- _os.kill(f_pid, _signal.SIGKILL)
1725
- except Exception as e:
1726
- err(f"SIGKILL failed: {e}")
1727
- return True
1841
+ if _is_alive(f_pid):
1842
+ warn(f"PID {f_pid} did not exit on SIGTERM — escalating to SIGKILL.")
1843
+ try:
1844
+ if _sys.platform == "win32":
1845
+ _sp.run(["taskkill", "/F", "/PID", str(f_pid)],
1846
+ capture_output=True, timeout=5)
1847
+ else:
1848
+ _os.kill(f_pid, _signal.SIGKILL)
1849
+ except Exception as e:
1850
+ err(f"SIGKILL failed: {e}")
1851
+ return True
1728
1852
 
1729
1853
  try:
1730
1854
  BG_PID.unlink()
@@ -1786,6 +1910,12 @@ def cmd_bg(args: str, _state, config) -> bool:
1786
1910
  from config import save_config
1787
1911
  save_config(config)
1788
1912
 
1913
+ # Snapshot current REPL session so the daemon can resume it.
1914
+ # This ensures Telegram/Web share the SAME session_id and context.
1915
+ current_sid = config.get("_session_id", "")
1916
+ if current_sid and _state and getattr(_state, "messages", None):
1917
+ save_latest("", _state, config)
1918
+
1789
1919
  # Build the spawn command. On Windows we MUST use pythonw.exe (windowless
1790
1920
  # variant) instead of the console-subsystem python.exe / dulus shim,
1791
1921
  # otherwise Windows creates a visible console window for the daemon
@@ -1817,6 +1947,7 @@ def cmd_bg(args: str, _state, config) -> bool:
1817
1947
  env = _os.environ.copy()
1818
1948
  env["DULUS_BG_AUTO_WEBCHAT"] = "1"
1819
1949
  env["DULUS_BG_WEBCHAT_PORT"] = str(web_port)
1950
+ env["DULUS_BG_SESSION_ID"] = current_sid
1820
1951
 
1821
1952
  # Detach properly per platform.
1822
1953
  log_fp = open(BG_LOG, "ab")
@@ -5740,10 +5871,27 @@ def _run_daemon(config: dict) -> None:
5740
5871
  from checkpoint import set_session
5741
5872
  from common import ok, info, warn, err, clr
5742
5873
 
5743
- session_id = config.get("_session_id") or uuid.uuid4().hex[:8]
5874
+ import os as _os_env
5875
+ bg_session_id = _os_env.environ.get("DULUS_BG_SESSION_ID", "")
5876
+ session_id = bg_session_id or config.get("_session_id") or uuid.uuid4().hex[:8]
5744
5877
  set_session(session_id)
5745
5878
 
5746
5879
  state = AgentState()
5880
+ # If spawned from /bg start with a session ID, resume that session's state.
5881
+ if bg_session_id:
5882
+ from config import MR_SESSION_DIR
5883
+ latest_path = MR_SESSION_DIR / "session_latest.json"
5884
+ if latest_path.exists():
5885
+ try:
5886
+ import json as _json
5887
+ data = _json.loads(latest_path.read_text(encoding="utf-8", errors="replace"))
5888
+ state.messages = data.get("messages", [])
5889
+ state.turn_count = data.get("turn_count", 0)
5890
+ state.total_input_tokens = data.get("total_input_tokens", 0)
5891
+ state.total_output_tokens = data.get("total_output_tokens", 0)
5892
+ info(f"Resumed session {session_id} ({len(state.messages)} messages)")
5893
+ except Exception as _load_e:
5894
+ warn(f"Could not resume session: {_load_e}")
5747
5895
  config["_state"] = state
5748
5896
  config["_session_id"] = session_id
5749
5897
  config["_last_interaction_time"] = time.time()
@@ -5769,6 +5917,33 @@ def _run_daemon(config: dict) -> None:
5769
5917
  err(f"daemon run_query error: {type(_e).__name__}: {_e}")
5770
5918
  config["_run_query_callback"] = _daemon_run_query
5771
5919
 
5920
+ # Register slash-command callback so Telegram and WebChat can run
5921
+ # /commands in daemon mode (without this, slash_cb is None and
5922
+ # commands are silently dropped).
5923
+ def _daemon_handle_slash(line: str):
5924
+ """Process a /command in daemon mode — mirrors the REPL callback."""
5925
+ result = handle_slash(line, state, config)
5926
+ if not isinstance(result, tuple):
5927
+ return "simple"
5928
+ if result[0] == "__brainstorm__":
5929
+ _, brain_prompt, brain_out_file = result
5930
+ _daemon_run_query(brain_prompt)
5931
+ _save_synthesis(state, brain_out_file)
5932
+ _todo_path = str(Path(brain_out_file).parent / "todo_list.txt")
5933
+ _daemon_run_query(
5934
+ f"Based on the Master Plan you just synthesized, generate a todo list file at {_todo_path}. "
5935
+ "Format: one task per line, each starting with '- [ ] '. "
5936
+ "Order by priority. Include ALL actionable items from the plan. "
5937
+ "Use the Write tool to create the file. Do NOT explain, just write the file now."
5938
+ )
5939
+ elif result[0] == "__worker__":
5940
+ _, worker_tasks = result
5941
+ for i, (line_idx, task_text, prompt) in enumerate(worker_tasks):
5942
+ _daemon_run_query(prompt)
5943
+ return "query"
5944
+
5945
+ config["_handle_slash_callback"] = _daemon_handle_slash
5946
+
5772
5947
  # Auto-start the webchat server alongside the daemon — always, by default.
5773
5948
  # The whole point of daemon mode is "headless Dulus serving every entry
5774
5949
  # point at once" (CLI via IPC, browser via WebChat, Telegram via bridge).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dulus"
7
- version = "0.2.30"
7
+ version = "0.2.32"
8
8
  description = "Spanish-first multi-provider AI CLI — 14 NVIDIA models free, Mesa Redonda, voice, TTS, RTK token reducer, MemPalace"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes