creatureos 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. creatureos/__init__.py +3 -0
  2. creatureos/__main__.py +7 -0
  3. creatureos/cli.py +604 -0
  4. creatureos/codex_cli.py +199 -0
  5. creatureos/config.py +236 -0
  6. creatureos/service.py +12586 -0
  7. creatureos/static/alien-picker-icon.svg +1 -0
  8. creatureos/static/alien-scene.svg +58 -0
  9. creatureos/static/apple-touch-icon.png +0 -0
  10. creatureos/static/console.css +5349 -0
  11. creatureos/static/creature_os.css +9636 -0
  12. creatureos/static/creature_os.js +3074 -0
  13. creatureos/static/creatureos-favicon.png +0 -0
  14. creatureos/static/creatureos-icon-192.png +0 -0
  15. creatureos/static/creatureos-icon-512.png +0 -0
  16. creatureos/static/creatureos-mark.png +0 -0
  17. creatureos/static/expanse-bridge-scene.svg +361 -0
  18. creatureos/static/expanse-hubble-xdf.jpg +0 -0
  19. creatureos/static/favicon-16x16.png +0 -0
  20. creatureos/static/favicon-32x32.png +0 -0
  21. creatureos/static/favicon.ico +0 -0
  22. creatureos/static/monsters-picker-icon.svg +1 -0
  23. creatureos/static/monsters-scene.svg +172 -0
  24. creatureos/static/ocean-picker-icon.svg +1 -0
  25. creatureos/static/ocean-scene.svg +99 -0
  26. creatureos/static/ocean-source-algae.svg +1 -0
  27. creatureos/static/ocean-source-clam-shell.svg +3 -0
  28. creatureos/static/ocean-source-coral.svg +1 -0
  29. creatureos/static/ocean-source-crab.svg +1 -0
  30. creatureos/static/ocean-source-fish-muted-flip.png +0 -0
  31. creatureos/static/ocean-source-fish-muted.png +0 -0
  32. creatureos/static/ocean-source-hammerhead-muted.png +0 -0
  33. creatureos/static/ocean-source-octopus-muted.png +0 -0
  34. creatureos/static/ocean-source-oyster-shell.svg +11 -0
  35. creatureos/static/ocean-source-seahorse.svg +77 -0
  36. creatureos/static/ocean-source-seaweed-tall.svg +66 -0
  37. creatureos/static/ocean-source-seaweed.svg +40 -0
  38. creatureos/static/ocean-source-shark-muted.png +0 -0
  39. creatureos/static/ocean-source-starfish.svg +1 -0
  40. creatureos/static/ocean-source-whale.svg +61 -0
  41. creatureos/static/shell.js +405 -0
  42. creatureos/static/site.webmanifest +19 -0
  43. creatureos/static/spooky-bone.svg +7 -0
  44. creatureos/static/spooky-flying-ghost.svg +9 -0
  45. creatureos/static/spooky-gate.svg +1487 -0
  46. creatureos/static/spooky-hasty-grave.svg +1 -0
  47. creatureos/static/spooky-midnight-graveyard.svg +2681 -0
  48. creatureos/static/spooky-picker-icon.svg +1 -0
  49. creatureos/static/spooky-scene.svg +88 -0
  50. creatureos/static/spooky-tombstone.svg +1 -0
  51. creatureos/static/spooky-zombie-hand.svg +1 -0
  52. creatureos/static/tech-picker-icon.svg +3 -0
  53. creatureos/static/tech-scene.svg +90 -0
  54. creatureos/static/tech-web-delivery-drone.svg +1 -0
  55. creatureos/static/tech-web-tracked-robot.svg +1 -0
  56. creatureos/static/tech-web-walking-scout.svg +1 -0
  57. creatureos/static/woodlands-beaver-soft.svg +100 -0
  58. creatureos/static/woodlands-birds.svg +7 -0
  59. creatureos/static/woodlands-branches-dark.svg +356 -0
  60. creatureos/static/woodlands-conifer-deep.svg +157 -0
  61. creatureos/static/woodlands-conifer-soft.svg +157 -0
  62. creatureos/static/woodlands-deer-soft.svg +119 -0
  63. creatureos/static/woodlands-eagle-soft.svg +105 -0
  64. creatureos/static/woodlands-fox-soft.svg +61 -0
  65. creatureos/static/woodlands-owl-soft.svg +99 -0
  66. creatureos/static/woodlands-rabbit-soft.svg +117 -0
  67. creatureos/static/woodlands-scene.svg +60 -0
  68. creatureos/static/woodlands-trees-mid-clipped-strong.png +0 -0
  69. creatureos/storage.py +2465 -0
  70. creatureos/templates/index.html +1443 -0
  71. creatureos/templates/layouts/console_base.html +88 -0
  72. creatureos/web.py +931 -0
  73. creatureos-0.1.0.dist-info/METADATA +248 -0
  74. creatureos-0.1.0.dist-info/RECORD +78 -0
  75. creatureos-0.1.0.dist-info/WHEEL +5 -0
  76. creatureos-0.1.0.dist-info/entry_points.txt +2 -0
  77. creatureos-0.1.0.dist-info/licenses/LICENSE +201 -0
  78. creatureos-0.1.0.dist-info/top_level.txt +1 -0
creatureos/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = []
creatureos/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
creatureos/cli.py ADDED
@@ -0,0 +1,604 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import ipaddress
6
+ import json
7
+ import os
8
+ import signal
9
+ import socket
10
+ import subprocess
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from urllib import request as urlrequest
14
+
15
+ import fcntl
16
+
17
+ import uvicorn
18
+
19
+ from . import config
20
+ from . import service
21
+ from . import storage
22
+
23
+ SERVER_WATCH_INTERVAL_SECONDS = 1.0
24
+ SERVER_RESTART_BACKOFF_SECONDS = 1.0
25
+ SERVER_SHUTDOWN_GRACE_SECONDS = 5.0
26
+ SERVER_READY_TIMEOUT_SECONDS = 45.0
27
+ SERVER_READY_POLL_INTERVAL_SECONDS = 0.5
28
+ SERVER_HEALTH_CHECK_INTERVAL_SECONDS = 5.0
29
+ SERVER_HEALTH_FAILURE_LIMIT = 3
30
+ SERVER_HEALTH_REQUEST_TIMEOUT_SECONDS = 5.0
31
+ SERVE_BIND_MODE_ENV = "CREATURE_OS_SERVE_BIND_MODE"
32
+ SERVE_BIND_MODE_DEFAULT = "default"
33
+ SERVE_BIND_MODE_TAILSCALE = "tailscale"
34
+ _TAILSCALE_IPV4_NETWORK = ipaddress.ip_network("100.64.0.0/10")
35
+
36
+
37
+ class _HiddenSubparsersAction(argparse._SubParsersAction):
38
+ def add_parser(self, name, **kwargs):
39
+ hidden = bool(kwargs.pop("hidden", False))
40
+ parser = super().add_parser(name, **kwargs)
41
+ if hidden and self._choices_actions:
42
+ self._choices_actions.pop()
43
+ return parser
44
+
45
+
46
+ def build_parser() -> argparse.ArgumentParser:
47
+ parser = argparse.ArgumentParser(description="CreatureOS runtime")
48
+ parser.register("action", "parsers", _HiddenSubparsersAction)
49
+ parser.add_argument(
50
+ "--workspace",
51
+ default="",
52
+ help="Workspace directory CreatureOS should inspect. Defaults to the current directory.",
53
+ )
54
+ parser.add_argument(
55
+ "--data-dir",
56
+ default="",
57
+ help="Runtime state directory. Defaults to a user-local CreatureOS state directory.",
58
+ )
59
+ parser.add_argument(
60
+ "--db-path",
61
+ default="",
62
+ help="SQLite path override. Defaults to <data-dir>/creature_os.sqlite3.",
63
+ )
64
+ subparsers = parser.add_subparsers(
65
+ dest="command",
66
+ required=True,
67
+ metavar="{init-db,run-creature,run-due,create-creature,send-message,create-conversation,spawn-conversation,delete-creature,serve}",
68
+ )
69
+
70
+ subparsers.add_parser("init-db")
71
+ run_creature = subparsers.add_parser("run-creature")
72
+ run_creature.add_argument("slug")
73
+ run_creature.add_argument("--trigger", default="manual")
74
+ run_creature.add_argument("--force-message", action="store_true")
75
+ run_creature.add_argument("--conversation-id", type=int)
76
+ run_creature.add_argument("--allow-code-changes", action="store_true")
77
+
78
+ run_due = subparsers.add_parser("run-due")
79
+ run_due.add_argument("--force-message", action="store_true")
80
+
81
+ create_creature = subparsers.add_parser("create-creature")
82
+ create_creature.add_argument("display_name")
83
+ create_creature.add_argument("concern")
84
+ create_creature.add_argument("--public-prompt", default="")
85
+ create_creature.add_argument("--slug", default="")
86
+
87
+ send_message = subparsers.add_parser("send-message")
88
+ send_message.add_argument("slug")
89
+ send_message.add_argument("conversation_id", type=int)
90
+ send_message.add_argument("body")
91
+
92
+ create_conversation = subparsers.add_parser("create-conversation")
93
+ create_conversation.add_argument("slug")
94
+ create_conversation.add_argument("--title", default="")
95
+
96
+ spawn_conversation = subparsers.add_parser("spawn-conversation")
97
+ spawn_conversation.add_argument("slug")
98
+ spawn_conversation.add_argument("run_id", type=int)
99
+
100
+ delete_creature = subparsers.add_parser("delete-creature")
101
+ delete_creature.add_argument("slug")
102
+
103
+ serve = subparsers.add_parser("serve")
104
+ serve.add_argument("--force-scan", action="store_true", help="Clear the cached onboarding environment scan before starting the server")
105
+ serve.add_argument(
106
+ "--tailscale",
107
+ action="store_true",
108
+ help="Serve on localhost plus the detected Tailscale IPv4. Falls back to localhost if Tailscale is unavailable.",
109
+ )
110
+ serve_worker = subparsers.add_parser("serve-worker", help=argparse.SUPPRESS, hidden=True)
111
+ serve_worker.add_argument("--force-scan", action="store_true", help=argparse.SUPPRESS)
112
+ serve_worker.add_argument("--tailscale", action="store_true", help=argparse.SUPPRESS)
113
+ return parser
114
+
115
+
116
+ def _normalize_bind_mode(value: str | None) -> str:
117
+ cleaned = str(value or "").strip().lower()
118
+ return SERVE_BIND_MODE_TAILSCALE if cleaned == SERVE_BIND_MODE_TAILSCALE else SERVE_BIND_MODE_DEFAULT
119
+
120
+
121
+ def _detect_tailscale_ipv4() -> str:
122
+ override = os.getenv("CREATURE_OS_TAILSCALE_IP", "").strip()
123
+ candidates: list[str] = [override] if override else []
124
+ if not candidates:
125
+ try:
126
+ result = subprocess.run(
127
+ ["tailscale", "ip", "-4"],
128
+ check=False,
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=2,
132
+ )
133
+ candidates = [line.strip() for line in result.stdout.splitlines() if line.strip()]
134
+ except Exception:
135
+ candidates = []
136
+ for candidate in candidates:
137
+ try:
138
+ ip = ipaddress.ip_address(candidate)
139
+ except ValueError:
140
+ continue
141
+ if ip.version == 4 and ip in _TAILSCALE_IPV4_NETWORK:
142
+ return str(ip)
143
+ return ""
144
+
145
+
146
+ def _server_display_urls(*, bind_mode: str) -> list[str]:
147
+ normalized_mode = _normalize_bind_mode(bind_mode)
148
+ urls = [f"http://127.0.0.1:{config.port()}/"]
149
+ if normalized_mode == SERVE_BIND_MODE_TAILSCALE:
150
+ tailscale_ip = _detect_tailscale_ipv4()
151
+ if tailscale_ip:
152
+ urls.append(f"http://{tailscale_ip}:{config.port()}/")
153
+ return urls
154
+
155
+
156
+ def _write_server_pid(
157
+ *,
158
+ supervisor_pid: int,
159
+ source_revision: str,
160
+ started_at: str,
161
+ worker_pid: int | None = None,
162
+ bind_mode: str = SERVE_BIND_MODE_DEFAULT,
163
+ ) -> None:
164
+ urls = _server_display_urls(bind_mode=bind_mode)
165
+ config.server_pid_path().write_text(
166
+ json.dumps(
167
+ {
168
+ "pid": supervisor_pid,
169
+ "worker_pid": int(worker_pid or 0),
170
+ "url": urls[0] if urls else config.app_url(),
171
+ "urls": urls,
172
+ "bind_mode": _normalize_bind_mode(bind_mode),
173
+ "started_at": started_at,
174
+ "source_revision": source_revision,
175
+ "static_version": config.static_version(source_revision),
176
+ },
177
+ sort_keys=True,
178
+ ),
179
+ encoding="utf-8",
180
+ )
181
+
182
+
183
+ def _remove_server_pid_if_owned(owner_pid: int) -> None:
184
+ pid_path = config.server_pid_path()
185
+ if not pid_path.exists():
186
+ return
187
+ try:
188
+ payload = json.loads(pid_path.read_text(encoding="utf-8"))
189
+ except Exception:
190
+ payload = {}
191
+ if int(payload.get("pid") or 0) == owner_pid:
192
+ pid_path.unlink(missing_ok=True)
193
+
194
+
195
+ def _terminate_worker(worker: subprocess.Popen[bytes] | None) -> None:
196
+ if worker is None or worker.poll() is not None:
197
+ return
198
+ try:
199
+ worker.terminate()
200
+ worker.wait(timeout=SERVER_SHUTDOWN_GRACE_SECONDS)
201
+ return
202
+ except subprocess.TimeoutExpired:
203
+ pass
204
+ except ProcessLookupError:
205
+ return
206
+ try:
207
+ worker.kill()
208
+ worker.wait(timeout=1)
209
+ except Exception:
210
+ return
211
+
212
+
213
+ def _launch_server_worker(source_revision: str, *, bind_mode: str) -> tuple[subprocess.Popen[bytes], str]:
214
+ booted_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
215
+ env = os.environ.copy()
216
+ env["CREATURE_OS_SOURCE_REVISION"] = source_revision
217
+ env["CREATURE_OS_STATIC_VERSION"] = config.static_version(source_revision)
218
+ env["CREATURE_OS_SERVER_BOOTED_AT"] = booted_at
219
+ env["CREATURE_OS_SUPERVISOR_PID"] = str(os.getpid())
220
+ env[SERVE_BIND_MODE_ENV] = _normalize_bind_mode(bind_mode)
221
+ worker_args = [config.python_bin(), "-m", "creatureos.cli", "serve-worker"]
222
+ if _normalize_bind_mode(bind_mode) == SERVE_BIND_MODE_TAILSCALE:
223
+ worker_args.append("--tailscale")
224
+ worker = subprocess.Popen(
225
+ worker_args,
226
+ cwd=str(config.package_root().parent),
227
+ env=env,
228
+ )
229
+ return worker, booted_at
230
+
231
+
232
+ def _create_listening_socket(host: str) -> socket.socket:
233
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
234
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
235
+ sock.bind((host, config.port()))
236
+ sock.listen(socket.SOMAXCONN)
237
+ sock.set_inheritable(True)
238
+ return sock
239
+
240
+
241
+ def _server_health_url() -> str:
242
+ return f"http://127.0.0.1:{config.port()}/healthz"
243
+
244
+
245
+ def _fetch_server_health(*, timeout_seconds: float = SERVER_HEALTH_REQUEST_TIMEOUT_SECONDS) -> dict[str, object] | None:
246
+ try:
247
+ with urlrequest.urlopen(_server_health_url(), timeout=timeout_seconds) as response:
248
+ if int(getattr(response, "status", 200) or 200) != 200:
249
+ return None
250
+ payload = json.loads(response.read().decode("utf-8"))
251
+ except Exception:
252
+ return None
253
+ if str(payload.get("status") or "").strip().lower() != "ok":
254
+ return None
255
+ return payload
256
+
257
+
258
+ def _remove_server_ready_file() -> None:
259
+ config.server_ready_path().unlink(missing_ok=True)
260
+
261
+
262
+ def _load_server_ready_payload() -> dict[str, object] | None:
263
+ path = config.server_ready_path()
264
+ if not path.exists():
265
+ return None
266
+ try:
267
+ payload = json.loads(path.read_text(encoding="utf-8"))
268
+ except Exception:
269
+ return None
270
+ return payload if isinstance(payload, dict) else None
271
+
272
+
273
+ def _server_ready_matches(
274
+ payload: dict[str, object] | None,
275
+ *,
276
+ source_revision: str,
277
+ worker_pid: int | None = None,
278
+ booted_at: str = "",
279
+ ) -> bool:
280
+ if not isinstance(payload, dict):
281
+ return False
282
+ if str(payload.get("status") or "").strip().lower() != "ready":
283
+ return False
284
+ if str(payload.get("source_revision") or "").strip() != str(source_revision).strip():
285
+ return False
286
+ if worker_pid is not None and int(payload.get("worker_pid") or 0) != int(worker_pid):
287
+ return False
288
+ if booted_at and str(payload.get("booted_at") or "").strip() != str(booted_at).strip():
289
+ return False
290
+ return True
291
+
292
+
293
+ def _server_health_matches(
294
+ payload: dict[str, object] | None,
295
+ *,
296
+ source_revision: str,
297
+ worker_pid: int | None = None,
298
+ booted_at: str = "",
299
+ ) -> bool:
300
+ if not isinstance(payload, dict):
301
+ return False
302
+ if str(payload.get("source_revision") or "").strip() != str(source_revision).strip():
303
+ return False
304
+ if worker_pid is not None and int(payload.get("worker_pid") or 0) != int(worker_pid):
305
+ return False
306
+ if booted_at and str(payload.get("booted_at") or "").strip() != str(booted_at).strip():
307
+ return False
308
+ return True
309
+
310
+
311
+ def _wait_for_worker_ready(
312
+ worker: subprocess.Popen[bytes],
313
+ *,
314
+ source_revision: str,
315
+ booted_at: str,
316
+ timeout_seconds: float = SERVER_READY_TIMEOUT_SECONDS,
317
+ ) -> bool:
318
+ deadline = time.monotonic() + max(1.0, timeout_seconds)
319
+ while time.monotonic() < deadline:
320
+ if worker.poll() is not None:
321
+ return False
322
+ payload = _load_server_ready_payload()
323
+ if _server_ready_matches(payload, source_revision=source_revision, worker_pid=worker.pid, booted_at=booted_at):
324
+ return True
325
+ time.sleep(SERVER_READY_POLL_INTERVAL_SECONDS)
326
+ return False
327
+
328
+
329
+ def _run_server_worker(*, force_scan: bool = False, bind_mode: str = SERVE_BIND_MODE_DEFAULT) -> int:
330
+ if force_scan:
331
+ storage.delete_meta(service.ONBOARDING_ENVIRONMENT_KEY)
332
+ storage.delete_meta(service.ONBOARDING_BRIEFING_KEY)
333
+ normalized_mode = _normalize_bind_mode(bind_mode)
334
+ if normalized_mode == SERVE_BIND_MODE_TAILSCALE:
335
+ tailscale_ip = _detect_tailscale_ipv4()
336
+ listen_hosts = ["127.0.0.1"]
337
+ if tailscale_ip and tailscale_ip not in listen_hosts:
338
+ listen_hosts.append(tailscale_ip)
339
+ sockets: list[socket.socket] = []
340
+ try:
341
+ for host in listen_hosts:
342
+ sockets.append(_create_listening_socket(host))
343
+ print("CreatureOS dual-bind mode:", flush=True)
344
+ print(f" Local: http://127.0.0.1:{config.port()}", flush=True)
345
+ if tailscale_ip:
346
+ print(f" Tailscale: http://{tailscale_ip}:{config.port()}", flush=True)
347
+ else:
348
+ print(" Tailscale: not detected, staying local-only", flush=True)
349
+ uvicorn_config = uvicorn.Config(
350
+ "creatureos.web:app",
351
+ host="127.0.0.1",
352
+ port=config.port(),
353
+ reload=False,
354
+ access_log=False,
355
+ log_config=None,
356
+ )
357
+ server = uvicorn.Server(uvicorn_config)
358
+ return 0 if asyncio.run(server.serve(sockets=sockets)) is not False else 1
359
+ finally:
360
+ for sock in sockets:
361
+ try:
362
+ sock.close()
363
+ except Exception:
364
+ pass
365
+ uvicorn.run(
366
+ "creatureos.web:app",
367
+ host=config.host(),
368
+ port=config.port(),
369
+ reload=False,
370
+ access_log=False,
371
+ log_config=None,
372
+ )
373
+ return 0
374
+
375
+
376
+ def _run_server_supervisor(*, force_scan: bool = False, bind_mode: str = SERVE_BIND_MODE_DEFAULT) -> int:
377
+ lock_path = config.server_lock_path()
378
+ pid_path = config.server_pid_path()
379
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
380
+ lock_file = lock_path.open("a+", encoding="utf-8")
381
+ try:
382
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
383
+ except OSError:
384
+ existing_pid = ""
385
+ if pid_path.exists():
386
+ try:
387
+ existing_pid = str(json.loads(pid_path.read_text(encoding="utf-8")).get("pid") or "").strip()
388
+ except Exception:
389
+ existing_pid = ""
390
+ detail = f" (pid {existing_pid})" if existing_pid else ""
391
+ print(f"CreatureOS server is already running for this habitat{detail}.", flush=True)
392
+ try:
393
+ lock_file.close()
394
+ except Exception:
395
+ pass
396
+ return 1
397
+
398
+ worker: subprocess.Popen[bytes] | None = None
399
+ normalized_bind_mode = _normalize_bind_mode(bind_mode)
400
+ os.environ[SERVE_BIND_MODE_ENV] = normalized_bind_mode
401
+ current_revision = config.server_source_revision()
402
+ worker_started_at = ""
403
+ state = {"shutdown": False, "reload": False}
404
+ launch_force_scan = bool(force_scan)
405
+ last_health_probe_at = 0.0
406
+ consecutive_health_failures = 0
407
+ launch_count = 0
408
+
409
+ def _supervisor_args(*, include_force_scan: bool = False) -> list[str]:
410
+ args = [config.python_bin(), "-m", "creatureos.cli", "serve"]
411
+ if normalized_bind_mode == SERVE_BIND_MODE_TAILSCALE:
412
+ args.append("--tailscale")
413
+ if include_force_scan:
414
+ args.append("--force-scan")
415
+ return args
416
+
417
+ def _handle_shutdown(signum: int, frame) -> None: # type: ignore[no-untyped-def]
418
+ state["shutdown"] = True
419
+ _terminate_worker(worker)
420
+
421
+ def _handle_reload(signum: int, frame) -> None: # type: ignore[no-untyped-def]
422
+ state["reload"] = True
423
+
424
+ def _reexec_supervisor(*, include_force_scan: bool = False) -> None:
425
+ os.execvpe(
426
+ config.python_bin(),
427
+ _supervisor_args(include_force_scan=include_force_scan),
428
+ os.environ.copy(),
429
+ )
430
+
431
+ signal.signal(signal.SIGTERM, _handle_shutdown)
432
+ signal.signal(signal.SIGINT, _handle_shutdown)
433
+ if hasattr(signal, "SIGHUP"):
434
+ signal.signal(signal.SIGHUP, _handle_reload)
435
+
436
+ try:
437
+ print("404: Humans not found.", flush=True)
438
+ print(f"Creatures awakening at {config.app_url().rstrip('/')}", flush=True)
439
+ print(f"Working root: {config.workspace_root()} ({config.workspace_root_source()})", flush=True)
440
+ print(f"Data dir: {config.data_dir()} ({config.data_dir_source()})", flush=True)
441
+ print(f"Database: {config.db_path()}", flush=True)
442
+ if config.workspace_root_source() == "cwd":
443
+ print(
444
+ "Tip: pass --workspace PATH or set CREATURE_OS_WORKSPACE_ROOT to keep creature file work anchored to a predictable directory.",
445
+ flush=True,
446
+ )
447
+ print("Onboarding scans look across likely work directories on this machine.", flush=True)
448
+ if normalized_bind_mode == SERVE_BIND_MODE_TAILSCALE:
449
+ for url in _server_display_urls(bind_mode=normalized_bind_mode):
450
+ print(f"Listening on {url.rstrip('/')}", flush=True)
451
+ _remove_server_ready_file()
452
+ if launch_force_scan:
453
+ storage.delete_meta(service.ONBOARDING_ENVIRONMENT_KEY)
454
+ storage.delete_meta(service.ONBOARDING_BRIEFING_KEY)
455
+ while not state["shutdown"]:
456
+ if state["reload"]:
457
+ _terminate_worker(worker)
458
+ print("Reload requested; restarting supervisor.", flush=True)
459
+ _reexec_supervisor(include_force_scan=launch_force_scan)
460
+
461
+ latest_revision = config.server_source_revision()
462
+ if latest_revision != current_revision:
463
+ _terminate_worker(worker)
464
+ print("Source revision changed; restarting supervisor.", flush=True)
465
+ _reexec_supervisor(include_force_scan=launch_force_scan)
466
+
467
+ if worker is None or worker.poll() is not None:
468
+ if state["shutdown"]:
469
+ break
470
+ if worker is not None:
471
+ print(
472
+ f"Worker exited with code {worker.returncode}; restarting in {SERVER_RESTART_BACKOFF_SECONDS:.1f}s.",
473
+ flush=True,
474
+ )
475
+ time.sleep(SERVER_RESTART_BACKOFF_SECONDS)
476
+ current_revision = config.server_source_revision()
477
+ _remove_server_ready_file()
478
+ worker, worker_started_at = _launch_server_worker(current_revision, bind_mode=normalized_bind_mode)
479
+ launch_count += 1
480
+ print(
481
+ f"Launching worker #{launch_count} ({normalized_bind_mode}) for revision {current_revision[:12]}.",
482
+ flush=True,
483
+ )
484
+ if not _wait_for_worker_ready(worker, source_revision=current_revision, booted_at=worker_started_at):
485
+ print(
486
+ f"Worker #{launch_count} failed to become ready within {SERVER_READY_TIMEOUT_SECONDS:.0f}s.",
487
+ flush=True,
488
+ )
489
+ _terminate_worker(worker)
490
+ worker = None
491
+ continue
492
+ launch_force_scan = False
493
+ consecutive_health_failures = 0
494
+ last_health_probe_at = time.monotonic()
495
+ _write_server_pid(
496
+ supervisor_pid=os.getpid(),
497
+ worker_pid=worker.pid,
498
+ source_revision=current_revision,
499
+ started_at=worker_started_at,
500
+ bind_mode=normalized_bind_mode,
501
+ )
502
+ continue
503
+
504
+ now = time.monotonic()
505
+ if now - last_health_probe_at >= SERVER_HEALTH_CHECK_INTERVAL_SECONDS:
506
+ last_health_probe_at = now
507
+ payload = _fetch_server_health()
508
+ if _server_health_matches(payload, source_revision=current_revision, worker_pid=worker.pid, booted_at=worker_started_at):
509
+ consecutive_health_failures = 0
510
+ else:
511
+ consecutive_health_failures += 1
512
+ print(
513
+ f"Health probe failed ({consecutive_health_failures}/{SERVER_HEALTH_FAILURE_LIMIT}); waiting for recovery.",
514
+ flush=True,
515
+ )
516
+ if consecutive_health_failures >= SERVER_HEALTH_FAILURE_LIMIT:
517
+ print("Worker stayed unhealthy; restarting it.", flush=True)
518
+ _terminate_worker(worker)
519
+ worker = None
520
+ consecutive_health_failures = 0
521
+ continue
522
+
523
+ time.sleep(SERVER_WATCH_INTERVAL_SECONDS)
524
+ return 0
525
+ finally:
526
+ _terminate_worker(worker)
527
+ _remove_server_ready_file()
528
+ try:
529
+ _remove_server_pid_if_owned(os.getpid())
530
+ except Exception:
531
+ pass
532
+ try:
533
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
534
+ except Exception:
535
+ pass
536
+ try:
537
+ lock_file.close()
538
+ except Exception:
539
+ pass
540
+
541
+
542
+ def main() -> int:
543
+ parser = build_parser()
544
+ args = parser.parse_args()
545
+ with config.override_runtime_paths(
546
+ workspace_root=args.workspace or None,
547
+ data_dir=args.data_dir or None,
548
+ db_path=args.db_path or None,
549
+ ):
550
+ if args.command == "init-db":
551
+ storage.init_db()
552
+ print(config.db_path())
553
+ return 0
554
+ if args.command == "run-creature":
555
+ result = service.run_creature(
556
+ args.slug,
557
+ trigger_type=args.trigger,
558
+ force_message=bool(args.force_message),
559
+ conversation_id=args.conversation_id,
560
+ allow_code_changes=bool(args.allow_code_changes),
561
+ )
562
+ print(json.dumps(result, indent=2))
563
+ return 0
564
+ if args.command == "run-due":
565
+ result = service.run_due_creatures(force_message=bool(args.force_message))
566
+ print(json.dumps(result, indent=2))
567
+ return 0
568
+ if args.command == "create-creature":
569
+ result = service.create_creature(
570
+ display_name=args.display_name,
571
+ concern=args.concern,
572
+ public_prompt=args.public_prompt,
573
+ slug=args.slug or None,
574
+ )
575
+ print(json.dumps(result, indent=2))
576
+ return 0
577
+ if args.command == "send-message":
578
+ result = service.send_user_message(args.slug, args.conversation_id, args.body)
579
+ print(json.dumps(result, indent=2))
580
+ return 0
581
+ if args.command == "create-conversation":
582
+ result = service.create_conversation(args.slug, title=args.title or None)
583
+ print(json.dumps(result, indent=2))
584
+ return 0
585
+ if args.command == "spawn-conversation":
586
+ result = service.spawn_conversation_from_run(args.slug, args.run_id)
587
+ print(json.dumps(result, indent=2))
588
+ return 0
589
+ if args.command == "delete-creature":
590
+ service.delete_creature(args.slug)
591
+ print(json.dumps({"deleted": args.slug}, indent=2))
592
+ return 0
593
+ if args.command == "serve":
594
+ bind_mode = SERVE_BIND_MODE_TAILSCALE if bool(args.tailscale) else _normalize_bind_mode(os.getenv(SERVE_BIND_MODE_ENV))
595
+ return _run_server_supervisor(force_scan=bool(args.force_scan), bind_mode=bind_mode)
596
+ if args.command == "serve-worker":
597
+ bind_mode = SERVE_BIND_MODE_TAILSCALE if bool(args.tailscale) else _normalize_bind_mode(os.getenv(SERVE_BIND_MODE_ENV))
598
+ return _run_server_worker(force_scan=bool(args.force_scan), bind_mode=bind_mode)
599
+ parser.error(f"Unknown command: {args.command}")
600
+ return 2
601
+
602
+
603
+ if __name__ == "__main__":
604
+ raise SystemExit(main())