manigent-daemon 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Manigent Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: manigent-daemon
3
+ Version: 0.1.4
4
+ Summary: Connects one computer to Manigent so its agents can be seen and controlled from the web
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://manigent.ai
7
+ Project-URL: Repository, https://github.com/manigent-ai/manigent-daemon
8
+ Project-URL: Issues, https://github.com/manigent-ai/manigent-daemon/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: websockets>=12
18
+ Requires-Dist: typer>=0.12
19
+ Dynamic: license-file
20
+
21
+ # manigent-daemon
22
+
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
24
+
25
+ Connects one computer to [Manigent](https://manigent.ai), so you can see what
26
+ is running on it — and start or stop it — from the web instead of a terminal.
27
+
28
+ ## Why this is a separate package
29
+
30
+ It stays connected when every agent on the machine is dead.
31
+
32
+ That is the whole feature. An agent's own connection cannot tell you "nothing is
33
+ running here", because it is gone in that case — which looks exactly like the
34
+ computer being switched off. Those two states need different words and different
35
+ remedies, so something on the machine has to keep talking when there is nothing
36
+ else left to talk.
37
+
38
+ It follows that this must not be able to break the way the agents can, which is
39
+ why it is its own package with **three dependencies** (`httpx`, `websockets`,
40
+ `typer`). It installs in seconds and starts even when the runner's heavier stack
41
+ is unusable.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ uv tool install manigent-daemon # or: pip install manigent-daemon
47
+ ```
48
+
49
+ Then, in the Manigent web app, open **Computers**, press **+** to get a pairing
50
+ code, and run:
51
+
52
+ ```bash
53
+ manigent computer install --code XXXX-XXXX --user <your-username>
54
+ ```
55
+
56
+ (`manigent computer …` is the same program, reached through the runner's CLI.
57
+ If the runner is not installed, use `manigent-computer …` directly.)
58
+
59
+ ## What this connection can do
60
+
61
+ **The fixed list.** The server can ask this computer to do only these things,
62
+ and every item is checked twice — once by the server before it will send
63
+ anything, and again here before anything runs:
64
+
65
+ - Start one of your agents
66
+ - Stop one of your agents
67
+ - Restart one of your agents
68
+ - Report which of them are running
69
+ - Update the Manigent software on it (manigent and its connection software)
70
+
71
+ **Your agents.** The agents it runs act as you and can run commands on this
72
+ computer.
73
+
74
+ **The terminal.** On by default: you can open a full shell on this computer, as
75
+ you, from its page in Manigent. Turn it off on this machine — only here, never
76
+ from the browser:
77
+
78
+ manigent computer terminal disable # ends open sessions within seconds
79
+ manigent computer terminal enable
80
+ manigent computer terminal status
81
+
82
+ Sessions are hung up when you end them, after 15 minutes idle, after 8 hours,
83
+ or when the connection drops. The server records who opened a session and for
84
+ how long — never what was typed or printed.
85
+
86
+ **Never**
87
+
88
+ - Read or change your agents' environment variables
89
+ - Install or change anything else
90
+ - Delete an agent — only you can, in the browser
91
+ - Reach any other computer or account
92
+
93
+ Start, stop and restart are performed by shelling the ordinary
94
+ `manigent start` / `manigent stop` commands, so they behave exactly as they do
95
+ when you type them yourself.
96
+
97
+ Disconnecting the computer from the web app destroys this machine's key
98
+ immediately and for good; reconnecting needs a fresh pairing code.
99
+
100
+ ## Commands
101
+
102
+ | Command | What it does |
103
+ |---------|--------------|
104
+ | `manigent computer install --code XXXX-XXXX` | Connect this computer to your account |
105
+ | `manigent computer start` | Start the connection in the background |
106
+ | `manigent computer stop` | Stop the connection (your agents keep running) |
107
+ | `manigent computer status` | Is it connected, and what is running here |
108
+ | `manigent computer logs --tail 50` | What the connection has been doing |
109
+ | `manigent computer update` | Update Manigent on this computer (manigent + connection software, via uv tool / pipx / pip) |
110
+
111
+ ## Where things live
112
+
113
+ ```
114
+ ~/.manigent/computer/<your-username>/
115
+ config.json # this machine's key, for this account (mode 0600)
116
+ computer.pid
117
+ stdout.log # what the connection did
118
+ stderr.log # what went wrong
119
+ ```
120
+
121
+ One directory per Manigent account. If two people (or two of your own
122
+ accounts) use the same computer, each pairs separately and gets its own key,
123
+ its own connection and its own logs — neither can disturb the other. Every
124
+ command takes `--user <name>`; without it, it acts for the account you are
125
+ logged in as.
126
+
127
+ The two logs are rotated at 2 MB, stay on this machine, and are never uploaded
128
+ or shown in the web app. `manigent computer logs` prints the tail of both.
129
+
130
+ `config.json` holds the only secret that lets Manigent ask this machine to do
131
+ anything. It is written readable by you alone, never logged, and never
132
+ synchronized anywhere.
133
+
134
+ ## Mirrored source
135
+
136
+ This repository is a read-only mirror, published from the Manigent monorepo.
137
+ Issues and discussion are welcome here; code changes land upstream.
138
+
139
+ ## License
140
+
141
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,121 @@
1
+ # manigent-daemon
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+
5
+ Connects one computer to [Manigent](https://manigent.ai), so you can see what
6
+ is running on it — and start or stop it — from the web instead of a terminal.
7
+
8
+ ## Why this is a separate package
9
+
10
+ It stays connected when every agent on the machine is dead.
11
+
12
+ That is the whole feature. An agent's own connection cannot tell you "nothing is
13
+ running here", because it is gone in that case — which looks exactly like the
14
+ computer being switched off. Those two states need different words and different
15
+ remedies, so something on the machine has to keep talking when there is nothing
16
+ else left to talk.
17
+
18
+ It follows that this must not be able to break the way the agents can, which is
19
+ why it is its own package with **three dependencies** (`httpx`, `websockets`,
20
+ `typer`). It installs in seconds and starts even when the runner's heavier stack
21
+ is unusable.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ uv tool install manigent-daemon # or: pip install manigent-daemon
27
+ ```
28
+
29
+ Then, in the Manigent web app, open **Computers**, press **+** to get a pairing
30
+ code, and run:
31
+
32
+ ```bash
33
+ manigent computer install --code XXXX-XXXX --user <your-username>
34
+ ```
35
+
36
+ (`manigent computer …` is the same program, reached through the runner's CLI.
37
+ If the runner is not installed, use `manigent-computer …` directly.)
38
+
39
+ ## What this connection can do
40
+
41
+ **The fixed list.** The server can ask this computer to do only these things,
42
+ and every item is checked twice — once by the server before it will send
43
+ anything, and again here before anything runs:
44
+
45
+ - Start one of your agents
46
+ - Stop one of your agents
47
+ - Restart one of your agents
48
+ - Report which of them are running
49
+ - Update the Manigent software on it (manigent and its connection software)
50
+
51
+ **Your agents.** The agents it runs act as you and can run commands on this
52
+ computer.
53
+
54
+ **The terminal.** On by default: you can open a full shell on this computer, as
55
+ you, from its page in Manigent. Turn it off on this machine — only here, never
56
+ from the browser:
57
+
58
+ manigent computer terminal disable # ends open sessions within seconds
59
+ manigent computer terminal enable
60
+ manigent computer terminal status
61
+
62
+ Sessions are hung up when you end them, after 15 minutes idle, after 8 hours,
63
+ or when the connection drops. The server records who opened a session and for
64
+ how long — never what was typed or printed.
65
+
66
+ **Never**
67
+
68
+ - Read or change your agents' environment variables
69
+ - Install or change anything else
70
+ - Delete an agent — only you can, in the browser
71
+ - Reach any other computer or account
72
+
73
+ Start, stop and restart are performed by shelling the ordinary
74
+ `manigent start` / `manigent stop` commands, so they behave exactly as they do
75
+ when you type them yourself.
76
+
77
+ Disconnecting the computer from the web app destroys this machine's key
78
+ immediately and for good; reconnecting needs a fresh pairing code.
79
+
80
+ ## Commands
81
+
82
+ | Command | What it does |
83
+ |---------|--------------|
84
+ | `manigent computer install --code XXXX-XXXX` | Connect this computer to your account |
85
+ | `manigent computer start` | Start the connection in the background |
86
+ | `manigent computer stop` | Stop the connection (your agents keep running) |
87
+ | `manigent computer status` | Is it connected, and what is running here |
88
+ | `manigent computer logs --tail 50` | What the connection has been doing |
89
+ | `manigent computer update` | Update Manigent on this computer (manigent + connection software, via uv tool / pipx / pip) |
90
+
91
+ ## Where things live
92
+
93
+ ```
94
+ ~/.manigent/computer/<your-username>/
95
+ config.json # this machine's key, for this account (mode 0600)
96
+ computer.pid
97
+ stdout.log # what the connection did
98
+ stderr.log # what went wrong
99
+ ```
100
+
101
+ One directory per Manigent account. If two people (or two of your own
102
+ accounts) use the same computer, each pairs separately and gets its own key,
103
+ its own connection and its own logs — neither can disturb the other. Every
104
+ command takes `--user <name>`; without it, it acts for the account you are
105
+ logged in as.
106
+
107
+ The two logs are rotated at 2 MB, stay on this machine, and are never uploaded
108
+ or shown in the web app. `manigent computer logs` prints the tail of both.
109
+
110
+ `config.json` holds the only secret that lets Manigent ask this machine to do
111
+ anything. It is written readable by you alone, never logged, and never
112
+ synchronized anywhere.
113
+
114
+ ## Mirrored source
115
+
116
+ This repository is a read-only mirror, published from the Manigent monorepo.
117
+ Issues and discussion are welcome here; code changes land upstream.
118
+
119
+ ## License
120
+
121
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,276 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ manigent/utils/agent_processes.py
4
+
5
+ Agent discovery and process liveness on ONE machine — the single copy of the
6
+ logic that answers "which agents are set up here, and which of them are
7
+ actually running right now".
8
+
9
+ Extracted from ``cli_lifecycle.py`` because it now has two consumers that must
10
+ never disagree:
11
+
12
+ - ``manigent/cli_lifecycle.py`` — the ``manigent start / stop / status``
13
+ commands a human runs in a terminal.
14
+ - ``manigent_daemon/`` — the connection daemon shipped as the separate
15
+ ``manigent-daemon`` distribution, which reports the same facts to the
16
+ server so the web UI can show them.
17
+
18
+ The daemon is a DIFFERENT distribution and cannot import ``manigent`` (that
19
+ is the whole point — it must install in seconds and start even when the
20
+ runner's heavy dependency stack is broken). ``scripts/build-daemon-package.sh``
21
+ therefore copies this file verbatim into the daemon wheel as
22
+ ``manigent_daemon/agent_processes.py``, and ``manigent_daemon/discovery.py``
23
+ imports whichever copy exists. Two consequences, both load-bearing:
24
+
25
+ 1. **Stdlib only.** No ``manigent.*`` import, no third-party import, not even
26
+ ``typer``. Anything added here that is not in the standard library breaks
27
+ the daemon's "tiny dependencies" guarantee. Reporting is the caller's job:
28
+ the functions here return values and never print.
29
+ 2. **Public names only.** ``discovery.py`` re-exports with ``import *``, which
30
+ skips underscore-prefixed names. A helper that needs to be visible to the
31
+ daemon must not start with ``_``.
32
+
33
+ ``cli_lifecycle`` keeps its historical ``_``-prefixed aliases (``_pid_is_alive``
34
+ and friends) so existing imports and tests continue to resolve.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import os
39
+ import signal
40
+ import subprocess
41
+ import sys
42
+ import time
43
+ from pathlib import Path
44
+
45
+ IS_WINDOWS = sys.platform == "win32"
46
+
47
+ # How long a graceful stop is given before escalating to a force kill. Kept
48
+ # here rather than at the call sites so a terminal `manigent stop` and a
49
+ # remote stop driven from the web behave identically.
50
+ STOP_GRACE_SECONDS = 5.0
51
+ _STOP_POLL_SECONDS = 0.25
52
+
53
+
54
+ def popen_detached_kwargs() -> dict:
55
+ """Popen kwargs that detach the child so it outlives the parent shell.
56
+
57
+ Windows needs DETACHED_PROCESS (no inherited console) plus
58
+ CREATE_NEW_PROCESS_GROUP (so we can later deliver CTRL_BREAK_EVENT).
59
+ POSIX just needs start_new_session=True.
60
+ """
61
+ if IS_WINDOWS:
62
+ flags = (
63
+ getattr(subprocess, "DETACHED_PROCESS", 0)
64
+ | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
65
+ )
66
+ return {"creationflags": flags}
67
+ return {"start_new_session": True}
68
+
69
+
70
+ def pid_is_alive(pid: int) -> bool:
71
+ """Check whether a PID refers to a live process, without signaling it.
72
+
73
+ On Windows, ``os.kill(pid, 0)`` actually terminates the target — so we
74
+ must use a non-signaling query (tasklist) instead.
75
+ """
76
+ if IS_WINDOWS:
77
+ result = subprocess.run(
78
+ ["tasklist", "/FI", f"PID eq {pid}", "/NH", "/FO", "CSV"],
79
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
80
+ text=True, check=False,
81
+ )
82
+ return f'"{pid}"' in (result.stdout or "")
83
+ try:
84
+ os.kill(pid, 0)
85
+ return True
86
+ except (OSError, ProcessLookupError):
87
+ return False
88
+
89
+
90
+ def signal_terminate(pid: int) -> None:
91
+ """Send a graceful termination request. Silently no-ops if the target is gone.
92
+
93
+ POSIX: SIGTERM. Windows: CTRL_BREAK_EVENT to the process group (works
94
+ because agents are spawned with CREATE_NEW_PROCESS_GROUP).
95
+ """
96
+ try:
97
+ if IS_WINDOWS:
98
+ os.kill(pid, getattr(signal, "CTRL_BREAK_EVENT", 15))
99
+ else:
100
+ os.kill(pid, signal.SIGTERM)
101
+ except (OSError, ProcessLookupError):
102
+ pass
103
+
104
+
105
+ def signal_kill(pid: int) -> None:
106
+ """Force-kill a process. Silently no-ops on failure.
107
+
108
+ POSIX: SIGKILL. Windows: ``taskkill /F`` — reliable even when graceful
109
+ signaling didn't land.
110
+ """
111
+ try:
112
+ if IS_WINDOWS:
113
+ subprocess.run(
114
+ ["taskkill", "/F", "/PID", str(pid)],
115
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
116
+ check=False,
117
+ )
118
+ else:
119
+ os.kill(pid, signal.SIGKILL)
120
+ except OSError:
121
+ pass
122
+
123
+
124
+ def read_pid(pid_file: Path) -> int | None:
125
+ """The live PID recorded in ``pid_file``, or None.
126
+
127
+ None covers all three "not running" shapes with one answer: no pidfile, an
128
+ unreadable/garbage pidfile, and a pidfile naming a process that has since
129
+ died (the stale-pidfile case). Callers that need to tell the last one apart
130
+ check ``pid_file.exists()`` themselves.
131
+ """
132
+ if not pid_file.exists():
133
+ return None
134
+ try:
135
+ pid = int(pid_file.read_text().strip())
136
+ return pid if pid_is_alive(pid) else None
137
+ except (ValueError, OSError):
138
+ return None
139
+
140
+
141
+ def stop_pid(pid_file: Path) -> int | None:
142
+ """Stop the process named by ``pid_file``. Returns the PID it stopped, else None.
143
+
144
+ Graceful first (SIGTERM / CTRL_BREAK_EVENT), then a force kill after a
145
+ :data:`STOP_GRACE_SECONDS` grace period (SIGKILL / taskkill /F). The
146
+ pidfile is removed either way, including when it was already stale — that
147
+ cleanup is why a caller should prefer this over signalling by hand.
148
+
149
+ Returns None when there was nothing to stop, so a caller can distinguish
150
+ "stopped it" from "it wasn't running" without a second probe. Never prints:
151
+ the terminal CLI and the remote daemon word the outcome differently.
152
+ """
153
+ if not pid_file.exists():
154
+ return None
155
+ try:
156
+ pid = int(pid_file.read_text().strip())
157
+ except (ValueError, OSError):
158
+ pid_file.unlink(missing_ok=True)
159
+ return None
160
+
161
+ if not pid_is_alive(pid):
162
+ pid_file.unlink(missing_ok=True)
163
+ return None
164
+
165
+ signal_terminate(pid)
166
+ for _ in range(int(STOP_GRACE_SECONDS / _STOP_POLL_SECONDS)):
167
+ time.sleep(_STOP_POLL_SECONDS)
168
+ if not pid_is_alive(pid):
169
+ break
170
+ else:
171
+ signal_kill(pid)
172
+
173
+ pid_file.unlink(missing_ok=True)
174
+ return pid
175
+
176
+
177
+ def agents_dir(data_dir: Path) -> Path:
178
+ """``{data_dir}/agents`` — where every locally registered agent lives."""
179
+ return Path(data_dir).expanduser() / "agents"
180
+
181
+
182
+ def prefixed_name(username: str, agent_name: str) -> str:
183
+ """``budget-analyst`` -> ``alice-budget-analyst`` (idempotent)."""
184
+ prefix = f"{username}-"
185
+ return agent_name if agent_name.startswith(prefix) else f"{prefix}{agent_name}"
186
+
187
+
188
+ def find_agent_dir(agents_root: Path, prefixed: str) -> Path | None:
189
+ """Find an agent's directory matching ``{prefixed}-{id}/``.
190
+
191
+ Requires ``credential.json`` so a half-registered directory is invisible,
192
+ the same rule :func:`list_owned_agent_short_names` applies.
193
+ """
194
+ if not agents_root.exists():
195
+ return None
196
+ for d in agents_root.iterdir():
197
+ if d.is_dir() and d.name.startswith(f"{prefixed}-"):
198
+ if (d / "credential.json").exists():
199
+ return d
200
+ return None
201
+
202
+
203
+ def list_owned_agent_short_names(agents_root: Path, username: str) -> list[str]:
204
+ """Return owned agents' short names by globbing the filesystem.
205
+
206
+ Pattern: ``{agents_root}/{username}-{short}-{id}/`` with ``credential.json``
207
+ present. Skips ``DELETED-*`` (renamed by self-destruct) and any dir
208
+ without a ``credential.json`` (half-registered). The trailing ``-{id}``
209
+ is stripped off the right.
210
+
211
+ Deliberately a filesystem glob rather than a read of ``settings.json``:
212
+ the directory is what a process can actually be started from, so an agent
213
+ that exists on disk is reported even if some config file forgot it.
214
+ """
215
+ if not agents_root.exists():
216
+ return []
217
+ prefix = f"{username}-"
218
+ names: list[str] = []
219
+ for entry in sorted(agents_root.iterdir()):
220
+ if not entry.is_dir() or entry.name.startswith("DELETED-"):
221
+ continue
222
+ if not entry.name.startswith(prefix):
223
+ continue
224
+ if not (entry / "credential.json").exists():
225
+ continue
226
+ rest = entry.name[len(prefix):]
227
+ short = rest.rsplit("-", 1)[0] if "-" in rest else rest
228
+ if short:
229
+ names.append(short)
230
+ return names
231
+
232
+
233
+ def agent_pid_file(agent_dir: Path) -> Path:
234
+ """The pidfile ``manigent start`` writes for one agent."""
235
+ return Path(agent_dir) / "agent.pid"
236
+
237
+
238
+ def scan_agents(agents_root: Path, username: str) -> list[dict]:
239
+ """One dict per locally registered agent, with its OBSERVED run state.
240
+
241
+ ``[{"short_name", "name", "dir", "pid", "state"}, …]`` sorted by
242
+ ``short_name``, where ``state`` is one of:
243
+
244
+ - ``"running"`` — the pidfile names a live process.
245
+ - ``"crashed"`` — a pidfile exists but the process is gone. The machine
246
+ started this agent and it exited without being asked to; the web UI
247
+ shows it as "Stopped on its own" so the user can tell it apart from a
248
+ stop they performed.
249
+ - ``"stopped"`` — no pidfile. Never started, or stopped cleanly.
250
+
251
+ Observed, never remembered: the state is read off the filesystem on every
252
+ call, so an agent that died without saying goodbye reads as ``crashed``
253
+ within one scan rather than lingering as "running" until something notices.
254
+ """
255
+ rows: list[dict] = []
256
+ for short in list_owned_agent_short_names(agents_root, username):
257
+ full = prefixed_name(username, short)
258
+ agent_dir = find_agent_dir(agents_root, full)
259
+ if agent_dir is None:
260
+ continue
261
+ pid_file = agent_pid_file(agent_dir)
262
+ pid = read_pid(pid_file)
263
+ if pid is not None:
264
+ state = "running"
265
+ elif pid_file.exists():
266
+ state = "crashed"
267
+ else:
268
+ state = "stopped"
269
+ rows.append({
270
+ "short_name": short,
271
+ "name": full,
272
+ "dir": str(agent_dir),
273
+ "pid": pid,
274
+ "state": state,
275
+ })
276
+ return rows