automud 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.
automud-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charles Norton
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.
automud-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: automud
3
+ Version: 0.1.0
4
+ Summary: Persistent telnet/MUD session manager you drive by hand or with an agent. No LLM, no API key.
5
+ Author: Charles Norton
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/CharlesCNorton/automud
8
+ Project-URL: Source, https://github.com/CharlesCNorton/automud
9
+ Keywords: mud,telnet,gmcp,agent,cli
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Games/Entertainment
15
+ Classifier: Topic :: Communications
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # AutoMUD
22
+
23
+ A persistent telnet/MUD session you drive with small, discrete commands. There is no
24
+ language model and no API key inside it: the intelligence is whoever runs it, a person, a
25
+ script, or an autonomous agent. It exists because a raw `telnet` session is interactive and
26
+ blocking, so it cannot be held open across separate shell commands. AutoMUD keeps the
27
+ connection alive in a small background daemon and exposes simple verbs against it.
28
+
29
+ ## Install
30
+
31
+ ```
32
+ pipx install git+https://github.com/CharlesCNorton/automud
33
+ ```
34
+
35
+ Or from a clone:
36
+
37
+ ```
38
+ pip install .
39
+ ```
40
+
41
+ Standard library only, Python 3.8+.
42
+
43
+ ## Use
44
+
45
+ ```
46
+ automud connect --demo achaea # or: automud connect <host> <port>
47
+ automud send 2 # send a line, print the reply
48
+ automud send Maelvorn
49
+ automud recv # drain any new output
50
+ automud state # structured game state (GMCP) as JSON
51
+ automud status
52
+ automud close
53
+ ```
54
+
55
+ | verb | what it does |
56
+ |------|--------------|
57
+ | `connect HOST PORT` / `--demo NAME` | open a session and start the daemon |
58
+ | `send TEXT` | send one line, print what comes back |
59
+ | `recv` | print any new output |
60
+ | `state [--key PKG]` | captured GMCP state as JSON (e.g. `--key Char.Vitals`) |
61
+ | `status` | connection and vitals summary |
62
+ | `log [--tail N]` | full session transcript |
63
+ | `close` | end the session and stop the daemon |
64
+
65
+ Built-in demo targets: `achaea`, `zork` (telehack.com), `chess` (freechess.org).
66
+
67
+ ## Behaviour
68
+
69
+ - **Smart waiting.** `send` and `recv` return as soon as the server stops talking, either a
70
+ telnet GA/EOR prompt marker or output going quiet, so you never guess a sleep duration.
71
+ `--max` caps the wait and `--quiet` sets the idle threshold.
72
+ - **GMCP.** It negotiates GMCP and parses the structured state modern MUDs push (health,
73
+ room, exits, skills) into JSON for `state`. Options it does not implement (compression,
74
+ MSDP, MXP) are refused rather than mishandled.
75
+ - **One session at a time**, held by a background daemon; a new `connect` replaces it.
76
+ Session state and the transcript live under a temp directory (override with `AUTOMUD_DIR`).
77
+
78
+ ## License
79
+
80
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,60 @@
1
+ # AutoMUD
2
+
3
+ A persistent telnet/MUD session you drive with small, discrete commands. There is no
4
+ language model and no API key inside it: the intelligence is whoever runs it, a person, a
5
+ script, or an autonomous agent. It exists because a raw `telnet` session is interactive and
6
+ blocking, so it cannot be held open across separate shell commands. AutoMUD keeps the
7
+ connection alive in a small background daemon and exposes simple verbs against it.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pipx install git+https://github.com/CharlesCNorton/automud
13
+ ```
14
+
15
+ Or from a clone:
16
+
17
+ ```
18
+ pip install .
19
+ ```
20
+
21
+ Standard library only, Python 3.8+.
22
+
23
+ ## Use
24
+
25
+ ```
26
+ automud connect --demo achaea # or: automud connect <host> <port>
27
+ automud send 2 # send a line, print the reply
28
+ automud send Maelvorn
29
+ automud recv # drain any new output
30
+ automud state # structured game state (GMCP) as JSON
31
+ automud status
32
+ automud close
33
+ ```
34
+
35
+ | verb | what it does |
36
+ |------|--------------|
37
+ | `connect HOST PORT` / `--demo NAME` | open a session and start the daemon |
38
+ | `send TEXT` | send one line, print what comes back |
39
+ | `recv` | print any new output |
40
+ | `state [--key PKG]` | captured GMCP state as JSON (e.g. `--key Char.Vitals`) |
41
+ | `status` | connection and vitals summary |
42
+ | `log [--tail N]` | full session transcript |
43
+ | `close` | end the session and stop the daemon |
44
+
45
+ Built-in demo targets: `achaea`, `zork` (telehack.com), `chess` (freechess.org).
46
+
47
+ ## Behaviour
48
+
49
+ - **Smart waiting.** `send` and `recv` return as soon as the server stops talking, either a
50
+ telnet GA/EOR prompt marker or output going quiet, so you never guess a sleep duration.
51
+ `--max` caps the wait and `--quiet` sets the idle threshold.
52
+ - **GMCP.** It negotiates GMCP and parses the structured state modern MUDs push (health,
53
+ room, exits, skills) into JSON for `state`. Options it does not implement (compression,
54
+ MSDP, MXP) are refused rather than mishandled.
55
+ - **One session at a time**, held by a background daemon; a new `connect` replaces it.
56
+ Session state and the transcript live under a temp directory (override with `AUTOMUD_DIR`).
57
+
58
+ ## License
59
+
60
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: automud
3
+ Version: 0.1.0
4
+ Summary: Persistent telnet/MUD session manager you drive by hand or with an agent. No LLM, no API key.
5
+ Author: Charles Norton
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/CharlesCNorton/automud
8
+ Project-URL: Source, https://github.com/CharlesCNorton/automud
9
+ Keywords: mud,telnet,gmcp,agent,cli
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Games/Entertainment
15
+ Classifier: Topic :: Communications
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # AutoMUD
22
+
23
+ A persistent telnet/MUD session you drive with small, discrete commands. There is no
24
+ language model and no API key inside it: the intelligence is whoever runs it, a person, a
25
+ script, or an autonomous agent. It exists because a raw `telnet` session is interactive and
26
+ blocking, so it cannot be held open across separate shell commands. AutoMUD keeps the
27
+ connection alive in a small background daemon and exposes simple verbs against it.
28
+
29
+ ## Install
30
+
31
+ ```
32
+ pipx install git+https://github.com/CharlesCNorton/automud
33
+ ```
34
+
35
+ Or from a clone:
36
+
37
+ ```
38
+ pip install .
39
+ ```
40
+
41
+ Standard library only, Python 3.8+.
42
+
43
+ ## Use
44
+
45
+ ```
46
+ automud connect --demo achaea # or: automud connect <host> <port>
47
+ automud send 2 # send a line, print the reply
48
+ automud send Maelvorn
49
+ automud recv # drain any new output
50
+ automud state # structured game state (GMCP) as JSON
51
+ automud status
52
+ automud close
53
+ ```
54
+
55
+ | verb | what it does |
56
+ |------|--------------|
57
+ | `connect HOST PORT` / `--demo NAME` | open a session and start the daemon |
58
+ | `send TEXT` | send one line, print what comes back |
59
+ | `recv` | print any new output |
60
+ | `state [--key PKG]` | captured GMCP state as JSON (e.g. `--key Char.Vitals`) |
61
+ | `status` | connection and vitals summary |
62
+ | `log [--tail N]` | full session transcript |
63
+ | `close` | end the session and stop the daemon |
64
+
65
+ Built-in demo targets: `achaea`, `zork` (telehack.com), `chess` (freechess.org).
66
+
67
+ ## Behaviour
68
+
69
+ - **Smart waiting.** `send` and `recv` return as soon as the server stops talking, either a
70
+ telnet GA/EOR prompt marker or output going quiet, so you never guess a sleep duration.
71
+ `--max` caps the wait and `--quiet` sets the idle threshold.
72
+ - **GMCP.** It negotiates GMCP and parses the structured state modern MUDs push (health,
73
+ room, exits, skills) into JSON for `state`. Options it does not implement (compression,
74
+ MSDP, MXP) are refused rather than mishandled.
75
+ - **One session at a time**, held by a background daemon; a new `connect` replaces it.
76
+ Session state and the transcript live under a temp directory (override with `AUTOMUD_DIR`).
77
+
78
+ ## License
79
+
80
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ automud.py
4
+ pyproject.toml
5
+ automud.egg-info/PKG-INFO
6
+ automud.egg-info/SOURCES.txt
7
+ automud.egg-info/dependency_links.txt
8
+ automud.egg-info/entry_points.txt
9
+ automud.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ automud = automud:main
@@ -0,0 +1 @@
1
+ automud
@@ -0,0 +1,605 @@
1
+ """
2
+ AutoMUD: a persistent telnet/MUD session you drive by hand (or an agent drives).
3
+
4
+ There is no LLM in here and no API key. The intelligence is whoever runs it: a person, a
5
+ script, or an autonomous agent. It solves the parts of a live MUD connection that a
6
+ normal shell can't: a raw telnet session is interactive and blocking, so it can't be held
7
+ across separate commands. A small background daemon keeps the connection open and you talk
8
+ to it with discrete verbs:
9
+
10
+ python AutoMUD.py connect --demo achaea # or: connect achaea.com 23
11
+ python AutoMUD.py send 2 # send a line, print the reply
12
+ python AutoMUD.py send Maelvorn
13
+ python AutoMUD.py recv # drain any new output
14
+ python AutoMUD.py state # structured game state (GMCP) as JSON
15
+ python AutoMUD.py status
16
+ python AutoMUD.py close
17
+
18
+ What the daemon does for you:
19
+ * Smart waiting: send/recv return as soon as the server stops talking (an IAC GA/EOR
20
+ prompt marker, or output going quiet), so you never guess a sleep duration. --max caps
21
+ the wait; --quiet sets the idle threshold.
22
+ * Prompt aware: it treats the telnet GA/EOR marker most MUDs send after a prompt as the
23
+ "your turn" signal, and refuses Suppress-Go-Ahead so that marker keeps flowing.
24
+ * GMCP capture: it negotiates GMCP and parses the structured state modern MUDs push
25
+ (Char.Vitals, Room.Info, etc.) into JSON you can read with `state`. It refuses every
26
+ other option it doesn't understand (compression, MSDP, MXP) rather than choking on it.
27
+
28
+ Config (optional):
29
+ AUTOMUD_DIR : session/state directory (default: <tempdir>/automud)
30
+ """
31
+
32
+ import argparse
33
+ import asyncio
34
+ import json
35
+ import os
36
+ import re
37
+ import socket
38
+ import subprocess
39
+ import sys
40
+ import tempfile
41
+ import time
42
+ from typing import Optional
43
+
44
+ AUTOMUD_DIR = os.environ.get("AUTOMUD_DIR") or os.path.join(tempfile.gettempdir(), "automud")
45
+ SESSION_JSON = os.path.join(AUTOMUD_DIR, "session.json")
46
+ OUT_LOG = os.path.join(AUTOMUD_DIR, "out.log")
47
+ DAEMON_LOG = os.path.join(AUTOMUD_DIR, "daemon.log")
48
+
49
+ # Keep at most this many characters of received text in memory (the full stream still goes
50
+ # to OUT_LOG). Trimming only ever drops already-read history, never unread output.
51
+ BUFFER_CAP = 1_000_000
52
+
53
+ DEMOS = {
54
+ "zork": ("telehack.com", 23),
55
+ "chess": ("freechess.org", 5000),
56
+ "achaea": ("achaea.com", 23),
57
+ }
58
+
59
+ # Telnet command bytes
60
+ IAC, DONT, DO, WONT, WILL, SB, SE = 255, 254, 253, 252, 251, 250, 240
61
+ GA, EOR_CMD = 249, 239
62
+ # Telnet options
63
+ OPT_GMCP, OPT_EOR = 201, 25
64
+ # Options we ask the server to enable. GMCP gives structured state; EOR gives prompt markers.
65
+ # We deliberately do NOT request SGA (option 3): suppressing Go-Ahead would kill the other
66
+ # prompt marker. Everything not listed here is refused, which also keeps us from accidentally
67
+ # enabling compression (MCCP) and turning the stream into zlib garbage.
68
+ WANT_DO = {OPT_GMCP, OPT_EOR}
69
+
70
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[()][AB012]|[\x00-\x08\x0b\x0c\x0e-\x1f]")
71
+
72
+
73
+ def strip_ansi(text: str) -> str:
74
+ return _ANSI_RE.sub("", text or "")
75
+
76
+
77
+ # ------------------------------ session state files ------------------------------
78
+
79
+ def _write_session(data: dict) -> None:
80
+ os.makedirs(AUTOMUD_DIR, exist_ok=True)
81
+ tmp = SESSION_JSON + ".tmp"
82
+ with open(tmp, "w", encoding="utf-8") as f:
83
+ json.dump(data, f)
84
+ os.replace(tmp, SESSION_JSON)
85
+
86
+
87
+ def _read_session() -> Optional[dict]:
88
+ try:
89
+ with open(SESSION_JSON, "r", encoding="utf-8") as f:
90
+ return json.load(f)
91
+ except Exception:
92
+ return None
93
+
94
+
95
+ # ------------------------------ telnet / GMCP parser ------------------------------
96
+
97
+ class MudConn:
98
+ """Minimal telnet client: separates plain text from IAC control, answers option
99
+ negotiation (only on state change, so it can't loop), captures GMCP subnegotiation as
100
+ JSON, and flags GA/EOR prompt markers.
101
+
102
+ Receipt accounting uses monotonic counters, not buffer indices, so trimming the in-memory
103
+ buffer never disturbs the read cursor or the wait logic:
104
+ total : chars ever received
105
+ read : chars handed to the client
106
+ base : chars dropped off the front of `buffer` (buffer == stream[base:total])
107
+ """
108
+
109
+ def __init__(self, writer: asyncio.StreamWriter, state: dict, log_fh):
110
+ self.w = writer
111
+ self.s = state
112
+ self.log = log_fh
113
+ self.mode = "text" # text | iac | neg | sb | sbiac
114
+ self.cmd = None
115
+ self.sb_opt = None
116
+ self.sb = bytearray()
117
+ self.text = bytearray()
118
+ self.him = {} # server-side option enabled? (None unknown)
119
+ self.us = {} # our-side option enabled?
120
+
121
+ # ---- byte stream ----
122
+ def feed(self, data: bytes) -> None:
123
+ i, n = 0, len(data)
124
+ while i < n:
125
+ if self.mode == "text":
126
+ k = data.find(IAC, i)
127
+ if k == -1:
128
+ self.text += data[i:n]
129
+ break
130
+ if k > i:
131
+ self.text += data[i:k]
132
+ self.mode = "iac"
133
+ i = k + 1
134
+ else:
135
+ self._byte(data[i])
136
+ i += 1
137
+ self._flush_text()
138
+
139
+ def _byte(self, b: int) -> None:
140
+ m = self.mode
141
+ if m == "iac":
142
+ if b == IAC:
143
+ self.text.append(IAC) # escaped 0xFF
144
+ self.mode = "text"
145
+ elif b in (DO, DONT, WILL, WONT):
146
+ self.cmd = b
147
+ self.mode = "neg"
148
+ elif b == SB:
149
+ self.sb_opt = None
150
+ self.sb = bytearray()
151
+ self.mode = "sb"
152
+ elif b in (GA, EOR_CMD):
153
+ self._prompt()
154
+ self.mode = "text"
155
+ else:
156
+ self.mode = "text" # other 1-byte commands: ignore
157
+ elif m == "neg":
158
+ self._negotiate(self.cmd, b)
159
+ self.mode = "text"
160
+ elif m == "sb":
161
+ if b == IAC:
162
+ self.mode = "sbiac"
163
+ elif self.sb_opt is None:
164
+ self.sb_opt = b
165
+ else:
166
+ self.sb.append(b)
167
+ elif m == "sbiac":
168
+ if b == IAC:
169
+ self.sb.append(IAC) # escaped 0xFF inside SB
170
+ self.mode = "sb"
171
+ elif b == SE:
172
+ self._subneg(self.sb_opt, bytes(self.sb))
173
+ self.mode = "text"
174
+ else:
175
+ self.mode = "text" # malformed; resync
176
+
177
+ # ---- handlers ----
178
+ def _append(self, s: str) -> None:
179
+ self.s["buffer"] += s
180
+ self.s["total"] += len(s)
181
+ self.s["last_rx"] = time.monotonic()
182
+ if self.log is not None:
183
+ try:
184
+ self.log.write(s)
185
+ self.log.flush()
186
+ except Exception:
187
+ pass
188
+ buf = self.s["buffer"]
189
+ if len(buf) > BUFFER_CAP:
190
+ drop = len(buf) - BUFFER_CAP
191
+ self.s["buffer"] = buf[drop:]
192
+ self.s["base"] += drop
193
+ if self.s["read"] < self.s["base"]: # dropped some unread; don't re-serve it
194
+ self.s["read"] = self.s["base"]
195
+
196
+ def _flush_text(self) -> None:
197
+ if not self.text:
198
+ return
199
+ s = strip_ansi(self.text.decode("utf-8", "replace"))
200
+ self.text = bytearray()
201
+ if s:
202
+ self._append(s)
203
+
204
+ def _prompt(self) -> None:
205
+ self._flush_text()
206
+ self.s["prompt_seen"] = True
207
+ self.s["last_rx"] = time.monotonic()
208
+
209
+ def _raw(self, *seq: int) -> None:
210
+ try:
211
+ self.w.write(bytes(seq))
212
+ except Exception:
213
+ pass
214
+
215
+ def _negotiate(self, cmd: int, opt: int) -> None:
216
+ # Respond only when the option's state actually changes, per the telnet Q-method,
217
+ # so a server that re-announces options can't make us loop.
218
+ if cmd == WILL:
219
+ want = opt in WANT_DO
220
+ if want and not self.him.get(opt):
221
+ self.him[opt] = True
222
+ self._raw(IAC, DO, opt)
223
+ if opt == OPT_GMCP:
224
+ self._gmcp_hello()
225
+ elif not want and self.him.get(opt) is not False:
226
+ self.him[opt] = False
227
+ self._raw(IAC, DONT, opt)
228
+ elif cmd == WONT:
229
+ if self.him.get(opt) is not False:
230
+ self.him[opt] = False
231
+ self._raw(IAC, DONT, opt)
232
+ elif cmd == DO:
233
+ if self.us.get(opt) is not False: # we enable nothing on our side
234
+ self.us[opt] = False
235
+ self._raw(IAC, WONT, opt)
236
+ elif cmd == DONT:
237
+ if self.us.get(opt) is not False:
238
+ self.us[opt] = False
239
+ self._raw(IAC, WONT, opt)
240
+
241
+ def _send_gmcp(self, package: str, payload: str) -> None:
242
+ msg = (package + " " + payload).encode("utf-8")
243
+ try:
244
+ self.w.write(bytes([IAC, SB, OPT_GMCP]) + msg + bytes([IAC, SE]))
245
+ except Exception:
246
+ pass
247
+
248
+ def _gmcp_hello(self) -> None:
249
+ self._send_gmcp("Core.Hello", '{"client":"AutoMUD","version":"1.0"}')
250
+ self._send_gmcp("Core.Supports.Set",
251
+ '["Char 1","Char.Vitals 1","Char.Status 1","Char.Skills 1",'
252
+ '"Room 1","Comm.Channel 1"]')
253
+
254
+ def _subneg(self, opt: int, payload: bytes) -> None:
255
+ if opt != OPT_GMCP:
256
+ return # MSDP/MXP/etc.: ignore, don't choke
257
+ text = payload.decode("utf-8", "replace")
258
+ sp = text.find(" ")
259
+ if sp == -1:
260
+ package, body = text.strip(), ""
261
+ else:
262
+ package, body = text[:sp].strip(), text[sp + 1:]
263
+ value = None
264
+ if body.strip():
265
+ try:
266
+ value = json.loads(body)
267
+ except Exception:
268
+ value = body
269
+ if package:
270
+ self.s["gmcp"][package] = value
271
+
272
+
273
+ # ------------------------------ daemon ------------------------------
274
+
275
+ async def _wait_settled(state: dict, since_total: int, quiet: float, maxw: float) -> None:
276
+ """Block until the server stops talking: a prompt marker arrived, or output produced
277
+ after `since_total` went idle for `quiet` seconds. Capped at `maxw`."""
278
+ start = time.monotonic()
279
+ # phase 1: wait for genuinely new output (or a prompt) after the snapshot
280
+ while time.monotonic() - start < maxw:
281
+ if state["total"] > since_total or state["prompt_seen"]:
282
+ break
283
+ if not state["connected"]:
284
+ return
285
+ await asyncio.sleep(0.05)
286
+ # phase 2: let the burst settle
287
+ while time.monotonic() - start < maxw:
288
+ if state["prompt_seen"]:
289
+ return
290
+ if time.monotonic() - state["last_rx"] >= quiet:
291
+ return
292
+ await asyncio.sleep(0.05)
293
+
294
+
295
+ def _drain(state: dict) -> str:
296
+ """Return all unread text and advance the read cursor."""
297
+ data = state["buffer"][state["read"] - state["base"]:]
298
+ state["read"] = state["total"]
299
+ state["prompt_seen"] = False
300
+ return data
301
+
302
+
303
+ async def _do_op(req: dict, state: dict, writer: asyncio.StreamWriter, stop: asyncio.Event) -> dict:
304
+ op = req.get("op")
305
+ quiet = float(req.get("quiet", 0.3))
306
+ maxw = float(req.get("max", 5.0))
307
+ if op == "send":
308
+ since = state["total"]
309
+ state["prompt_seen"] = False
310
+ try:
311
+ writer.write((req.get("data") or "").encode("utf-8").replace(b"\xff", b"\xff\xff") + b"\r\n")
312
+ await writer.drain()
313
+ except Exception as e:
314
+ return {"ok": False, "error": f"send failed: {e}"}
315
+ await _wait_settled(state, since, quiet, maxw)
316
+ return {"ok": True, "data": _drain(state), "connected": state["connected"]}
317
+ if op == "recv":
318
+ if req.get("block", True):
319
+ await _wait_settled(state, state["read"], quiet, maxw)
320
+ return {"ok": True, "data": _drain(state), "connected": state["connected"]}
321
+ if op == "state":
322
+ return {"ok": True, "gmcp": state["gmcp"], "connected": state["connected"]}
323
+ if op == "status":
324
+ vit = state["gmcp"].get("Char.Vitals") or {}
325
+ return {"ok": True, "connected": state["connected"],
326
+ "unread": state["total"] - state["read"], "total_chars": state["total"],
327
+ "gmcp_packages": sorted(state["gmcp"].keys()), "vitals": vit}
328
+ if op == "close":
329
+ stop.set()
330
+ return {"ok": True}
331
+ return {"ok": False, "error": f"unknown op '{op}'"}
332
+
333
+
334
+ async def _daemon_main(host: str, port: int) -> None:
335
+ os.makedirs(AUTOMUD_DIR, exist_ok=True)
336
+ try:
337
+ reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=20.0)
338
+ except Exception as e:
339
+ _write_session({"error": str(e), "host": host, "port": port})
340
+ return
341
+
342
+ try:
343
+ log_fh = open(OUT_LOG, "a", encoding="utf-8")
344
+ except Exception:
345
+ log_fh = None
346
+
347
+ state = {"buffer": "", "total": 0, "read": 0, "base": 0, "connected": True, "gmcp": {},
348
+ "last_rx": time.monotonic(), "prompt_seen": False}
349
+ conn = MudConn(writer, state, log_fh)
350
+ stop = asyncio.Event()
351
+
352
+ async def control(creader: asyncio.StreamReader, cwriter: asyncio.StreamWriter) -> None:
353
+ try:
354
+ line = await creader.readline()
355
+ req = json.loads(line.decode("utf-8", "replace") or "{}")
356
+ resp = await _do_op(req, state, writer, stop)
357
+ cwriter.write((json.dumps(resp) + "\n").encode("utf-8"))
358
+ await cwriter.drain()
359
+ except Exception as e:
360
+ try:
361
+ cwriter.write((json.dumps({"ok": False, "error": str(e)}) + "\n").encode("utf-8"))
362
+ await cwriter.drain()
363
+ except Exception:
364
+ pass
365
+ finally:
366
+ cwriter.close()
367
+
368
+ server = await asyncio.start_server(control, "127.0.0.1", 0)
369
+ ctrl_port = server.sockets[0].getsockname()[1]
370
+ _write_session({"host": host, "port": port, "control_port": ctrl_port, "pid": os.getpid()})
371
+
372
+ async def pump() -> None:
373
+ try:
374
+ while not stop.is_set():
375
+ data = await reader.read(4096)
376
+ if not data:
377
+ break
378
+ conn.feed(data)
379
+ finally:
380
+ state["connected"] = False
381
+
382
+ pump_task = asyncio.create_task(pump())
383
+ serve_task = asyncio.create_task(server.serve_forever())
384
+ await stop.wait()
385
+ state["connected"] = False
386
+ for t in (pump_task, serve_task):
387
+ t.cancel()
388
+ for closer in (lambda: writer.close(), lambda: log_fh and log_fh.close(),
389
+ lambda: os.remove(SESSION_JSON)):
390
+ try:
391
+ closer()
392
+ except Exception:
393
+ pass
394
+
395
+
396
+ # ------------------------------ client (the verbs) ------------------------------
397
+
398
+ def _control(op: str, **kw) -> dict:
399
+ sess = _read_session()
400
+ if not sess or "control_port" not in sess:
401
+ return {"ok": False, "error": "no active session (run 'connect' first)"}
402
+ try:
403
+ with socket.create_connection(("127.0.0.1", sess["control_port"]), timeout=30) as s:
404
+ s.sendall((json.dumps({"op": op, **kw}) + "\n").encode("utf-8"))
405
+ buf = b""
406
+ while not buf.endswith(b"\n"):
407
+ chunk = s.recv(65536)
408
+ if not chunk:
409
+ break
410
+ buf += chunk
411
+ return json.loads(buf.decode("utf-8", "replace"))
412
+ except Exception as e:
413
+ return {"ok": False, "error": str(e)}
414
+
415
+
416
+ def _spawn_daemon(host: str, port: int) -> None:
417
+ os.makedirs(AUTOMUD_DIR, exist_ok=True)
418
+ try:
419
+ os.remove(SESSION_JSON)
420
+ except Exception:
421
+ pass
422
+ open(OUT_LOG, "w", encoding="utf-8").close()
423
+ args = [sys.executable, os.path.abspath(__file__), "--daemon", host, str(port)]
424
+ logf = open(DAEMON_LOG, "a", encoding="utf-8")
425
+ kwargs: dict = {"stdout": logf, "stderr": logf, "stdin": subprocess.DEVNULL}
426
+ if os.name == "nt":
427
+ kwargs["creationflags"] = 0x00000008 | 0x00000200 # DETACHED_PROCESS | NEW_PROCESS_GROUP
428
+ else:
429
+ kwargs["start_new_session"] = True
430
+ subprocess.Popen(args, **kwargs)
431
+
432
+
433
+ def _print(text: str) -> None:
434
+ sys.stdout.write(text)
435
+ if text and not text.endswith("\n"):
436
+ sys.stdout.write("\n")
437
+
438
+
439
+ def cmd_connect(host: str, port: int, quiet: float, maxw: float) -> int:
440
+ old = _read_session()
441
+ if old and old.get("control_port"): # a real live daemon: ask it to exit
442
+ _control("close")
443
+ for _ in range(30): # and wait for it to clear its session file
444
+ if not os.path.exists(SESSION_JSON):
445
+ break
446
+ time.sleep(0.1)
447
+ _spawn_daemon(host, port)
448
+ deadline = time.time() + 25
449
+ sess = None
450
+ while time.time() < deadline:
451
+ sess = _read_session()
452
+ if sess:
453
+ break
454
+ time.sleep(0.3)
455
+ if not sess:
456
+ print(f"daemon did not start within 25s; see {DAEMON_LOG}")
457
+ return 1
458
+ if sess.get("error"):
459
+ print(f"connect failed: {sess['error']}")
460
+ return 1
461
+ print(f"connected to {host}:{port}")
462
+ _print(_control("recv", block=True, quiet=quiet, max=maxw).get("data", ""))
463
+ return 0
464
+
465
+
466
+ def cmd_send(text: str, quiet: float, maxw: float) -> int:
467
+ r = _control("send", data=text, quiet=quiet, max=maxw)
468
+ if not r.get("ok"):
469
+ print(f"send failed: {r.get('error')}")
470
+ return 1
471
+ _print(r.get("data", ""))
472
+ return 0
473
+
474
+
475
+ def cmd_recv(quiet: float, maxw: float) -> int:
476
+ r = _control("recv", block=True, quiet=quiet, max=maxw)
477
+ if not r.get("ok"):
478
+ print(f"recv failed: {r.get('error')}")
479
+ return 1
480
+ _print(r.get("data", ""))
481
+ return 0
482
+
483
+
484
+ def cmd_state(key: Optional[str]) -> int:
485
+ r = _control("state")
486
+ if not r.get("ok"):
487
+ print(f"no session: {r.get('error')}")
488
+ return 1
489
+ gmcp = r.get("gmcp", {})
490
+ if key:
491
+ if key not in gmcp:
492
+ print(f"no GMCP package '{key}' yet (have: {', '.join(sorted(gmcp)) or 'none'})")
493
+ return 1
494
+ print(json.dumps(gmcp[key], indent=2))
495
+ elif not gmcp:
496
+ print("no GMCP data yet (the server may not push it until you're in the game)")
497
+ else:
498
+ print(json.dumps(gmcp, indent=2))
499
+ return 0
500
+
501
+
502
+ def cmd_status() -> int:
503
+ r = _control("status")
504
+ if not r.get("ok"):
505
+ print(f"no session: {r.get('error')}")
506
+ return 1
507
+ vit = r.get("vitals") or {}
508
+ vit_str = f" vitals: hp={vit.get('hp')} mp={vit.get('mp')}" if vit else ""
509
+ print(f"connected={r['connected']} unread={r['unread']} chars "
510
+ f"gmcp=[{', '.join(r.get('gmcp_packages', []))}]" + vit_str)
511
+ return 0
512
+
513
+
514
+ def cmd_close() -> int:
515
+ r = _control("close")
516
+ print("closed" if r.get("ok") else f"close failed: {r.get('error')}")
517
+ return 0 if r.get("ok") else 1
518
+
519
+
520
+ def cmd_log(tail: int) -> int:
521
+ try:
522
+ with open(OUT_LOG, "r", encoding="utf-8") as f:
523
+ data = f.read()
524
+ except Exception:
525
+ print("no session log yet")
526
+ return 1
527
+ if tail > 0:
528
+ data = data[-tail:]
529
+ _print(data)
530
+ return 0
531
+
532
+
533
+ # ------------------------------ entrypoint ------------------------------
534
+
535
+ def build_parser() -> argparse.ArgumentParser:
536
+ p = argparse.ArgumentParser(
537
+ prog="AutoMUD",
538
+ description="Persistent telnet/MUD session driven by discrete verbs, with smart waiting "
539
+ "and GMCP capture. No LLM, no API key; the operator supplies the intelligence.",
540
+ formatter_class=argparse.RawDescriptionHelpFormatter,
541
+ epilog="Verbs: connect / send / recv / state / status / log / close. Demos: "
542
+ + ", ".join(sorted(DEMOS)) + ".")
543
+ sub = p.add_subparsers(dest="cmd", required=True)
544
+
545
+ def add_wait(sp):
546
+ sp.add_argument("--quiet", type=float, default=0.3,
547
+ help="seconds of silence that counts as 'server done' (default 0.3)")
548
+ sp.add_argument("--max", type=float, default=5.0,
549
+ help="hard cap on how long to wait for the reply (default 5)")
550
+
551
+ c = sub.add_parser("connect", help="open a session (starts the background daemon)")
552
+ c.add_argument("host", nargs="?", help="telnet host")
553
+ c.add_argument("port", nargs="?", type=int, help="telnet port")
554
+ c.add_argument("--demo", choices=sorted(DEMOS), help="use a built-in demo target")
555
+ add_wait(c)
556
+
557
+ s = sub.add_parser("send", help="send one line, then print the reply")
558
+ s.add_argument("text", nargs="+", help="the line to send (joined with spaces)")
559
+ add_wait(s)
560
+
561
+ r = sub.add_parser("recv", help="print any new output (waits for it to settle)")
562
+ add_wait(r)
563
+
564
+ st = sub.add_parser("state", help="print captured GMCP game state as JSON")
565
+ st.add_argument("--key", help="print only one package, e.g. Char.Vitals or Room.Info")
566
+
567
+ sub.add_parser("status", help="show connection + vitals summary")
568
+ sub.add_parser("close", help="close the session and stop the daemon")
569
+
570
+ lg = sub.add_parser("log", help="print the full session output log")
571
+ lg.add_argument("--tail", type=int, default=0, help="only the last N characters (0 = all)")
572
+ return p
573
+
574
+
575
+ def main() -> None:
576
+ if len(sys.argv) >= 2 and sys.argv[1] == "--daemon":
577
+ asyncio.run(_daemon_main(sys.argv[2], int(sys.argv[3])))
578
+ return
579
+
580
+ args = build_parser().parse_args()
581
+ if args.cmd == "connect":
582
+ if args.demo:
583
+ host, port = DEMOS[args.demo]
584
+ elif args.host and args.port:
585
+ host, port = args.host, args.port
586
+ else:
587
+ print("usage: connect HOST PORT | connect --demo NAME")
588
+ sys.exit(2)
589
+ sys.exit(cmd_connect(host, port, quiet=args.quiet, maxw=args.max))
590
+ elif args.cmd == "send":
591
+ sys.exit(cmd_send(" ".join(args.text), quiet=args.quiet, maxw=args.max))
592
+ elif args.cmd == "recv":
593
+ sys.exit(cmd_recv(quiet=args.quiet, maxw=args.max))
594
+ elif args.cmd == "state":
595
+ sys.exit(cmd_state(args.key))
596
+ elif args.cmd == "status":
597
+ sys.exit(cmd_status())
598
+ elif args.cmd == "close":
599
+ sys.exit(cmd_close())
600
+ elif args.cmd == "log":
601
+ sys.exit(cmd_log(tail=args.tail))
602
+
603
+
604
+ if __name__ == "__main__":
605
+ main()
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "automud"
7
+ version = "0.1.0"
8
+ description = "Persistent telnet/MUD session manager you drive by hand or with an agent. No LLM, no API key."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Charles Norton" }]
13
+ keywords = ["mud", "telnet", "gmcp", "agent", "cli"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Environment :: Console",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Games/Entertainment",
20
+ "Topic :: Communications",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/CharlesCNorton/automud"
25
+ Source = "https://github.com/CharlesCNorton/automud"
26
+
27
+ [project.scripts]
28
+ automud = "automud:main"
29
+
30
+ [tool.setuptools]
31
+ py-modules = ["automud"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+