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.
- {dulus-0.2.30/dulus.egg-info → dulus-0.2.32}/PKG-INFO +1 -1
- {dulus-0.2.30 → dulus-0.2.32/dulus.egg-info}/PKG-INFO +1 -1
- {dulus-0.2.30 → dulus-0.2.32}/dulus.py +223 -48
- {dulus-0.2.30 → dulus-0.2.32}/pyproject.toml +1 -1
- {dulus-0.2.30 → dulus-0.2.32}/LICENSE +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/MANIFEST.in +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/README.md +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/agent.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/compressor.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/context.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/githook.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/marketplace.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/mempalace_bridge.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/personas.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/plugins.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/server.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/backend/tasks.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/batch_api.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/checkpoint/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/checkpoint/hooks.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/checkpoint/store.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/checkpoint/types.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/claude_code_watcher.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/clipboard_utils.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/cloudsave.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/common.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/compaction.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/config.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/context.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/active_persona.json +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/context.json +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/marketplace.json +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/personas.json +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/plugins/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/composio_plugin/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/composio_plugin/session_manager.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/composio_plugin/tool_generator.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/plugin.json +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/plugins/composio/plugin_tool.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/data/tasks.json +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/README.md +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/api.html +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/architecture.md +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/azure-speech-template.json +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/dashboard/index.html +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/divider.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/generate.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/hero.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/index.html +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/news.md +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/nvidia-models.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/particle-playground.html +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/personas/index.html +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/poetry-banner.png +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/preview.html +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-agents.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-brainstorm.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-bridges.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-features.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-freetier.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-memory.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-models.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-perms.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-plugins.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-quickstart.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/sec-ssj.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/spinners.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/split-pane.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/terminal-boot.svg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/docs/uploads/particle-playground.html +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/SOURCES.txt +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/dependency_links.txt +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/entry_points.txt +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/requires.txt +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus.egg-info/top_level.txt +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus_gui.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/client.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/config.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/tools.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/dulus_mcp/types.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/agent_bridge.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/chat_widget.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/main_window.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/personas.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/session_utils.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/settings_dialog.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/sidebar.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/tasks_view.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/themes.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/gui/tool_panel.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/input.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/license_manager.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/audit.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/consolidator.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/context.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/offload.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/palace.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/scan.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/sessions.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/store.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/tools.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/types.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/memory/vector_search.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/multi_agent/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/multi_agent/subagent.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/multi_agent/tools.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/offload_helper.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/plugin/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/plugin/autoadapter.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/plugin/loader.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/plugin/recommend.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/plugin/store.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/plugin/types.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/providers.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/setup.cfg +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/skill/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/skill/builtin.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/skill/clawhub.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/skill/executor.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/skill/loader.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/skill/tools.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/skills.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/spinner.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/string_utils.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/subagent.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/task/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/task/store.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/task/tools.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/task/types.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_checkpoint.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_compaction.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_diff_view.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_injection_fix.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_license.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_mcp.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_memory.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_plugin.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_skills.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_subagent.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_task.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_telegram_buffer.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_tool_registry.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tests/test_voice.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tmux_offloader.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tmux_tools.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tool_registry.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/tools.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/ui/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/ui/input.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/ui/render.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/voice/__init__.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/voice/keyterms.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/voice/recorder.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/voice/stt.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/voice/tts.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/webchat.py +0 -0
- {dulus-0.2.30 → dulus-0.2.32}/webchat_server.py +0 -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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
-
|
|
1586
|
+
else:
|
|
1587
|
+
try:
|
|
1572
1588
|
_os.kill(pid, 0)
|
|
1573
1589
|
return True
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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("
|
|
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
|
-
|
|
1664
|
-
|
|
1665
|
-
info(f"
|
|
1666
|
-
info(f"
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
|
1674
|
-
#
|
|
1675
|
-
#
|
|
1676
|
-
#
|
|
1677
|
-
#
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|