clawmeets-daemon 0.1.0__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.
- clawmeets_daemon-0.1.0/LICENSE +21 -0
- clawmeets_daemon-0.1.0/PKG-INFO +129 -0
- clawmeets_daemon-0.1.0/README.md +109 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon/agent_processes.py +276 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon/cli.py +429 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon/client.py +290 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon/commands.py +321 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon/config.py +361 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon/discovery.py +36 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon/protocol.py +101 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon.egg-info/PKG-INFO +129 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon.egg-info/SOURCES.txt +16 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon.egg-info/dependency_links.txt +1 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon.egg-info/entry_points.txt +3 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon.egg-info/requires.txt +3 -0
- clawmeets_daemon-0.1.0/clawmeets_daemon.egg-info/top_level.txt +1 -0
- clawmeets_daemon-0.1.0/pyproject.toml +51 -0
- clawmeets_daemon-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ClawMeets 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,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clawmeets-daemon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Connects one computer to ClawMeets so its agents can be seen and controlled from the web
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://clawmeets.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/clawmeets-ai/clawmeets-daemon
|
|
8
|
+
Project-URL: Issues, https://github.com/clawmeets-ai/clawmeets-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
|
+
# clawmeets-daemon
|
|
22
|
+
|
|
23
|
+
[](https://opensource.org/licenses/MIT)
|
|
24
|
+
|
|
25
|
+
Connects one computer to [ClawMeets](https://clawmeets.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 clawmeets-daemon # or: pip install clawmeets-daemon
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then, in the ClawMeets web app, open **Computers**, press **+** to get a pairing
|
|
50
|
+
code, and run:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
clawmeets computer install --code XXXX-XXXX
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
(`clawmeets computer …` is the same program, reached through the runner's CLI.
|
|
57
|
+
If the runner is not installed, use `clawmeets-computer …` directly.)
|
|
58
|
+
|
|
59
|
+
## What ClawMeets may do on this computer
|
|
60
|
+
|
|
61
|
+
The complete list. It cannot be extended from the web, and every item is checked
|
|
62
|
+
twice — once by the server before it will send anything, and again here before
|
|
63
|
+
anything runs.
|
|
64
|
+
|
|
65
|
+
**Allowed**
|
|
66
|
+
|
|
67
|
+
- Start one of your agents
|
|
68
|
+
- Stop one of your agents
|
|
69
|
+
- Restart one of your agents
|
|
70
|
+
- Report which of them are running
|
|
71
|
+
- Update its own connection software
|
|
72
|
+
|
|
73
|
+
**Never**
|
|
74
|
+
|
|
75
|
+
- Run any other command
|
|
76
|
+
- Open, read, copy or send your files
|
|
77
|
+
- Install or change anything else
|
|
78
|
+
- Delete an agent — only you can, in the browser
|
|
79
|
+
- Reach any other computer or account
|
|
80
|
+
|
|
81
|
+
Start, stop and restart are performed by shelling the ordinary
|
|
82
|
+
`clawmeets start` / `clawmeets stop` commands, so they behave exactly as they do
|
|
83
|
+
when you type them yourself.
|
|
84
|
+
|
|
85
|
+
Disconnecting the computer from the web app destroys this machine's key
|
|
86
|
+
immediately and for good; reconnecting needs a fresh pairing code.
|
|
87
|
+
|
|
88
|
+
## Commands
|
|
89
|
+
|
|
90
|
+
| Command | What it does |
|
|
91
|
+
|---------|--------------|
|
|
92
|
+
| `clawmeets computer install --code XXXX-XXXX` | Connect this computer to your account |
|
|
93
|
+
| `clawmeets computer start` | Start the connection in the background |
|
|
94
|
+
| `clawmeets computer stop` | Stop the connection (your agents keep running) |
|
|
95
|
+
| `clawmeets computer status` | Is it connected, and what is running here |
|
|
96
|
+
| `clawmeets computer logs --tail 50` | What the connection has been doing |
|
|
97
|
+
| `clawmeets computer update` | Update this computer's connection software |
|
|
98
|
+
|
|
99
|
+
## Where things live
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
~/.clawmeets/computer/<your-username>/
|
|
103
|
+
config.json # this machine's key, for this account (mode 0600)
|
|
104
|
+
computer.pid
|
|
105
|
+
stdout.log # what the connection did
|
|
106
|
+
stderr.log # what went wrong
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
One directory per ClawMeets account. If two people (or two of your own
|
|
110
|
+
accounts) use the same computer, each pairs separately and gets its own key,
|
|
111
|
+
its own connection and its own logs — neither can disturb the other. Every
|
|
112
|
+
command takes `--user <name>`; without it, it acts for the account you are
|
|
113
|
+
logged in as.
|
|
114
|
+
|
|
115
|
+
The two logs are rotated at 2 MB, stay on this machine, and are never uploaded
|
|
116
|
+
or shown in the web app. `clawmeets computer logs` prints the tail of both.
|
|
117
|
+
|
|
118
|
+
`config.json` holds the only secret that lets ClawMeets ask this machine to do
|
|
119
|
+
anything. It is written readable by you alone, never logged, and never
|
|
120
|
+
synchronized anywhere.
|
|
121
|
+
|
|
122
|
+
## Mirrored source
|
|
123
|
+
|
|
124
|
+
This repository is a read-only mirror, published from the ClawMeets monorepo.
|
|
125
|
+
Issues and discussion are welcome here; code changes land upstream.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# clawmeets-daemon
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
|
|
5
|
+
Connects one computer to [ClawMeets](https://clawmeets.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 clawmeets-daemon # or: pip install clawmeets-daemon
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then, in the ClawMeets web app, open **Computers**, press **+** to get a pairing
|
|
30
|
+
code, and run:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
clawmeets computer install --code XXXX-XXXX
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
(`clawmeets computer …` is the same program, reached through the runner's CLI.
|
|
37
|
+
If the runner is not installed, use `clawmeets-computer …` directly.)
|
|
38
|
+
|
|
39
|
+
## What ClawMeets may do on this computer
|
|
40
|
+
|
|
41
|
+
The complete list. It cannot be extended from the web, and every item is checked
|
|
42
|
+
twice — once by the server before it will send anything, and again here before
|
|
43
|
+
anything runs.
|
|
44
|
+
|
|
45
|
+
**Allowed**
|
|
46
|
+
|
|
47
|
+
- Start one of your agents
|
|
48
|
+
- Stop one of your agents
|
|
49
|
+
- Restart one of your agents
|
|
50
|
+
- Report which of them are running
|
|
51
|
+
- Update its own connection software
|
|
52
|
+
|
|
53
|
+
**Never**
|
|
54
|
+
|
|
55
|
+
- Run any other command
|
|
56
|
+
- Open, read, copy or send your files
|
|
57
|
+
- Install or change anything else
|
|
58
|
+
- Delete an agent — only you can, in the browser
|
|
59
|
+
- Reach any other computer or account
|
|
60
|
+
|
|
61
|
+
Start, stop and restart are performed by shelling the ordinary
|
|
62
|
+
`clawmeets start` / `clawmeets stop` commands, so they behave exactly as they do
|
|
63
|
+
when you type them yourself.
|
|
64
|
+
|
|
65
|
+
Disconnecting the computer from the web app destroys this machine's key
|
|
66
|
+
immediately and for good; reconnecting needs a fresh pairing code.
|
|
67
|
+
|
|
68
|
+
## Commands
|
|
69
|
+
|
|
70
|
+
| Command | What it does |
|
|
71
|
+
|---------|--------------|
|
|
72
|
+
| `clawmeets computer install --code XXXX-XXXX` | Connect this computer to your account |
|
|
73
|
+
| `clawmeets computer start` | Start the connection in the background |
|
|
74
|
+
| `clawmeets computer stop` | Stop the connection (your agents keep running) |
|
|
75
|
+
| `clawmeets computer status` | Is it connected, and what is running here |
|
|
76
|
+
| `clawmeets computer logs --tail 50` | What the connection has been doing |
|
|
77
|
+
| `clawmeets computer update` | Update this computer's connection software |
|
|
78
|
+
|
|
79
|
+
## Where things live
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
~/.clawmeets/computer/<your-username>/
|
|
83
|
+
config.json # this machine's key, for this account (mode 0600)
|
|
84
|
+
computer.pid
|
|
85
|
+
stdout.log # what the connection did
|
|
86
|
+
stderr.log # what went wrong
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
One directory per ClawMeets account. If two people (or two of your own
|
|
90
|
+
accounts) use the same computer, each pairs separately and gets its own key,
|
|
91
|
+
its own connection and its own logs — neither can disturb the other. Every
|
|
92
|
+
command takes `--user <name>`; without it, it acts for the account you are
|
|
93
|
+
logged in as.
|
|
94
|
+
|
|
95
|
+
The two logs are rotated at 2 MB, stay on this machine, and are never uploaded
|
|
96
|
+
or shown in the web app. `clawmeets computer logs` prints the tail of both.
|
|
97
|
+
|
|
98
|
+
`config.json` holds the only secret that lets ClawMeets ask this machine to do
|
|
99
|
+
anything. It is written readable by you alone, never logged, and never
|
|
100
|
+
synchronized anywhere.
|
|
101
|
+
|
|
102
|
+
## Mirrored source
|
|
103
|
+
|
|
104
|
+
This repository is a read-only mirror, published from the ClawMeets monorepo.
|
|
105
|
+
Issues and discussion are welcome here; code changes land upstream.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""
|
|
3
|
+
clawmeets/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
|
+
- ``clawmeets/cli_lifecycle.py`` — the ``clawmeets start / stop / status``
|
|
13
|
+
commands a human runs in a terminal.
|
|
14
|
+
- ``clawmeets_daemon/`` — the connection daemon shipped as the separate
|
|
15
|
+
``clawmeets-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 ``clawmeets`` (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
|
+
``clawmeets_daemon/agent_processes.py``, and ``clawmeets_daemon/discovery.py``
|
|
23
|
+
imports whichever copy exists. Two consequences, both load-bearing:
|
|
24
|
+
|
|
25
|
+
1. **Stdlib only.** No ``clawmeets.*`` 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 `clawmeets 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 ``clawmeets 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
|