personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Cross-platform background service management for solstone.
5
6Usage:
7 sol service install [--port PORT] Install solstone as a background service
8 sol service uninstall Remove the background service
9 sol service start Start the background service
10 sol service stop Stop the background service
11 sol service restart [--if-installed] Restart the background service
12 sol service status Show service installation and runtime status
13 sol service logs View service logs
14 sol service logs -f Follow service logs
15
16 sol up Install (if needed), start, and show status
17 sol down Stop the background service
18
19Default convey port for installed services is 5015.
20"""
21
22from __future__ import annotations
23
24import os
25import plistlib
26import subprocess
27import sys
28from pathlib import Path
29
30from think.utils import get_journal, get_journal_info
31
32SERVICE_LABEL = "org.solpbc.solstone"
33SYSTEMD_UNIT = "solstone"
34DEFAULT_SERVICE_PORT = 5015
35
36
37def _platform() -> str:
38 """Return 'darwin', 'linux', or raise on unsupported."""
39 if sys.platform == "darwin":
40 return "darwin"
41 elif sys.platform.startswith("linux"):
42 return "linux"
43 else:
44 print(f"Error: unsupported platform '{sys.platform}'", file=sys.stderr)
45 sys.exit(1)
46
47
48def _plist_path() -> Path:
49 return Path.home() / "Library" / "LaunchAgents" / f"{SERVICE_LABEL}.plist"
50
51
52def _unit_path() -> Path:
53 return Path.home() / ".config" / "systemd" / "user" / f"{SYSTEMD_UNIT}.service"
54
55
56def _sol_bin() -> str:
57 """Return absolute path to the sol binary in the current venv."""
58 return str(Path(sys.executable).parent / "sol")
59
60
61def _collect_env() -> dict[str, str]:
62 """Collect environment variables for the service file.
63
64 Captures HOME and PATH (with venv bin prepended). The real PATH is read
65 from os.environ so installed services inherit the shell's tool visibility.
66 Falls back to /usr/local/bin:/usr/bin:/bin if PATH is unset. API keys are
67 NOT written into service files — the supervisor reads them from journal.json
68 at process startup via setup_cli(). Never propagate _SOLSTONE_JOURNAL_OVERRIDE
69 into service files — installed services should use default path resolution.
70 """
71 venv_bin = str(Path(sys.executable).parent)
72 base_path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
73 path = ":".join(dict.fromkeys([venv_bin] + base_path.split(":")))
74
75 return {
76 "HOME": str(Path.home()),
77 "PATH": path,
78 }
79
80
81def _generate_plist(env: dict[str, str], port: int = DEFAULT_SERVICE_PORT) -> bytes:
82 """Generate a launchd plist for the solstone supervisor."""
83 journal_path = str(Path(get_journal()).resolve())
84 sol = _sol_bin()
85
86 plist = {
87 "Label": SERVICE_LABEL,
88 "ProgramArguments": [sol, "supervisor", str(port)],
89 "EnvironmentVariables": env,
90 "RunAtLoad": True,
91 "KeepAlive": True,
92 "StandardOutPath": f"{journal_path}/health/launchd-stdout.log",
93 "StandardErrorPath": f"{journal_path}/health/launchd-stderr.log",
94 }
95 return plistlib.dumps(plist)
96
97
98def _generate_systemd_unit(
99 env: dict[str, str], port: int = DEFAULT_SERVICE_PORT
100) -> str:
101 """Generate a systemd user unit for the solstone supervisor."""
102 sol = _sol_bin()
103 env_lines = "\n".join(f"Environment={k}={v}" for k, v in sorted(env.items()))
104
105 return (
106 f"[Unit]\n"
107 f"Description=Solstone Supervisor\n"
108 f"After=default.target\n"
109 f"\n"
110 f"[Service]\n"
111 f"Type=simple\n"
112 f"ExecStart={sol} supervisor {port}\n"
113 f"Restart=on-failure\n"
114 f"RestartSec=5\n"
115 f"{env_lines}\n"
116 f"\n"
117 f"[Install]\n"
118 f"WantedBy=default.target\n"
119 )
120
121
122def _check_linger() -> None:
123 """Warn if systemd linger is not enabled for the current user."""
124 try:
125 result = subprocess.run(
126 ["loginctl", "show-user", os.environ.get("USER", ""), "--property=Linger"],
127 capture_output=True,
128 text=True,
129 timeout=5,
130 )
131 if result.returncode == 0 and "Linger=no" in result.stdout:
132 print(
133 "Warning: systemd linger is not enabled. "
134 "The service will stop when you log out.\n"
135 "Enable it with: sudo loginctl enable-linger $USER"
136 )
137 except (subprocess.TimeoutExpired, FileNotFoundError):
138 pass
139
140
141def _install(port: int = DEFAULT_SERVICE_PORT) -> int:
142 platform = _platform()
143 env = _collect_env()
144
145 journal_path, _source = get_journal_info()
146 Path(journal_path, "health").mkdir(parents=True, exist_ok=True)
147
148 if platform == "darwin":
149 plist_data = _generate_plist(env, port=port)
150 path = _plist_path()
151 path.parent.mkdir(parents=True, exist_ok=True)
152
153 uid = os.getuid()
154 subprocess.run(
155 ["launchctl", "bootout", f"gui/{uid}", str(path)],
156 capture_output=True,
157 )
158
159 path.write_bytes(plist_data)
160 print(f"Wrote {path}")
161
162 result = subprocess.run(
163 ["launchctl", "bootstrap", f"gui/{uid}", str(path)],
164 capture_output=True,
165 text=True,
166 )
167 if result.returncode != 0:
168 print(f"Error loading service: {result.stderr.strip()}", file=sys.stderr)
169 return 1
170 print("Service loaded into launchd")
171
172 else:
173 unit_content = _generate_systemd_unit(env, port=port)
174 path = _unit_path()
175 path.parent.mkdir(parents=True, exist_ok=True)
176 path.write_text(unit_content)
177 print(f"Wrote {path}")
178
179 subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
180 subprocess.run(["systemctl", "--user", "enable", SYSTEMD_UNIT], check=True)
181 print("Service enabled")
182
183 _check_linger()
184
185 return 0
186
187
188def _uninstall() -> int:
189 platform = _platform()
190
191 if platform == "darwin":
192 path = _plist_path()
193 uid = os.getuid()
194 subprocess.run(
195 ["launchctl", "bootout", f"gui/{uid}", str(path)],
196 capture_output=True,
197 )
198 if path.exists():
199 path.unlink()
200 print(f"Removed {path}")
201 else:
202 print("Service was not installed")
203
204 else:
205 path = _unit_path()
206 subprocess.run(
207 ["systemctl", "--user", "stop", SYSTEMD_UNIT],
208 capture_output=True,
209 )
210 subprocess.run(
211 ["systemctl", "--user", "disable", SYSTEMD_UNIT],
212 capture_output=True,
213 )
214 if path.exists():
215 path.unlink()
216 subprocess.run(
217 ["systemctl", "--user", "daemon-reload"],
218 capture_output=True,
219 )
220 print(f"Removed {path}")
221 else:
222 print("Service was not installed")
223
224 return 0
225
226
227def _start() -> int:
228 platform = _platform()
229 if platform == "darwin":
230 uid = os.getuid()
231 path = _plist_path()
232 if not path.exists():
233 print(
234 "Error: service not installed. Run 'sol service install' first.",
235 file=sys.stderr,
236 )
237 return 1
238 result = subprocess.run(
239 ["launchctl", "kickstart", f"gui/{uid}/{SERVICE_LABEL}"],
240 capture_output=True,
241 text=True,
242 )
243 if result.returncode != 0:
244 print(f"Error starting service: {result.stderr.strip()}", file=sys.stderr)
245 return 1
246 else:
247 if not _unit_path().exists():
248 print(
249 "Error: service not installed. Run 'sol service install' first.",
250 file=sys.stderr,
251 )
252 return 1
253 result = subprocess.run(
254 ["systemctl", "--user", "start", SYSTEMD_UNIT],
255 capture_output=True,
256 text=True,
257 )
258 if result.returncode != 0:
259 print(f"Error starting service: {result.stderr.strip()}", file=sys.stderr)
260 return 1
261
262 print("Service started")
263 return 0
264
265
266def _stop() -> int:
267 platform = _platform()
268 if platform == "darwin":
269 uid = os.getuid()
270 result = subprocess.run(
271 ["launchctl", "kill", "SIGTERM", f"gui/{uid}/{SERVICE_LABEL}"],
272 capture_output=True,
273 text=True,
274 )
275 if result.returncode != 0:
276 print(f"Error stopping service: {result.stderr.strip()}", file=sys.stderr)
277 return 1
278 else:
279 result = subprocess.run(
280 ["systemctl", "--user", "stop", SYSTEMD_UNIT],
281 capture_output=True,
282 text=True,
283 )
284 if result.returncode != 0:
285 print(f"Error stopping service: {result.stderr.strip()}", file=sys.stderr)
286 return 1
287
288 print("Service stopped")
289 return 0
290
291
292def _restart(if_installed: bool = False) -> int:
293 platform = _platform()
294 if platform == "darwin":
295 installed = _plist_path().exists()
296 else:
297 installed = _unit_path().exists()
298
299 if not installed:
300 if if_installed:
301 return 0
302 print(
303 "Error: service not installed. Run 'sol service install' first.",
304 file=sys.stderr,
305 )
306 return 1
307
308 if platform == "darwin":
309 uid = os.getuid()
310 subprocess.run(
311 ["launchctl", "kill", "SIGTERM", f"gui/{uid}/{SERVICE_LABEL}"],
312 capture_output=True,
313 )
314 result = subprocess.run(
315 ["launchctl", "kickstart", f"gui/{uid}/{SERVICE_LABEL}"],
316 capture_output=True,
317 text=True,
318 )
319 if result.returncode != 0:
320 print(f"Error restarting service: {result.stderr.strip()}", file=sys.stderr)
321 return 1
322 else:
323 result = subprocess.run(
324 ["systemctl", "--user", "restart", SYSTEMD_UNIT],
325 capture_output=True,
326 text=True,
327 )
328 if result.returncode != 0:
329 print(f"Error restarting service: {result.stderr.strip()}", file=sys.stderr)
330 return 1
331
332 print("Service restarted")
333 return 0
334
335
336def _status() -> int:
337 platform = _platform()
338
339 if platform == "darwin":
340 installed = _plist_path().exists()
341 else:
342 installed = _unit_path().exists()
343
344 if not installed:
345 print("Service: not installed")
346 print("Run 'sol service install' to install, or 'sol up' to install and start.")
347 return 1
348
349 print("Service: installed")
350
351 if platform == "darwin":
352 uid = os.getuid()
353 result = subprocess.run(
354 ["launchctl", "print", f"gui/{uid}/{SERVICE_LABEL}"],
355 capture_output=True,
356 text=True,
357 )
358 if result.returncode == 0:
359 print("State: running (launchd)")
360 else:
361 print("State: stopped")
362 return 0
363 else:
364 result = subprocess.run(
365 ["systemctl", "--user", "is-active", SYSTEMD_UNIT],
366 capture_output=True,
367 text=True,
368 )
369 state = result.stdout.strip()
370 if state == "active":
371 print("State: running (systemd)")
372 else:
373 print(f"State: {state}")
374 return 0
375
376 print()
377 from think.health_cli import health_check
378
379 return health_check()
380
381
382def _logs(follow: bool = False) -> int:
383 platform = _platform()
384
385 if platform == "linux":
386 cmd = ["journalctl", "--user", "-u", SYSTEMD_UNIT, "--no-pager", "-n", "100"]
387 if follow:
388 cmd.append("--follow")
389 result = subprocess.run(cmd)
390 return result.returncode
391 else:
392 journal_path = Path(get_journal())
393 stdout_log = journal_path / "health" / "launchd-stdout.log"
394 stderr_log = journal_path / "health" / "launchd-stderr.log"
395
396 if follow:
397 logs_to_follow = [str(p) for p in [stdout_log, stderr_log] if p.exists()]
398 if not logs_to_follow:
399 print("No service log files found", file=sys.stderr)
400 return 1
401 result = subprocess.run(["/usr/bin/tail", "-f"] + logs_to_follow)
402 return result.returncode
403 else:
404 for log_path in [stdout_log, stderr_log]:
405 if log_path.exists():
406 print(f"=== {log_path.name} ===")
407 print(log_path.read_text(errors="replace")[-10000:])
408 else:
409 print(f"=== {log_path.name} === (not found)")
410 return 0
411
412
413def _up(port: int = DEFAULT_SERVICE_PORT) -> int:
414 """Install if needed, start if not running, show status."""
415 platform = _platform()
416
417 if platform == "darwin":
418 installed = _plist_path().exists()
419 else:
420 installed = _unit_path().exists()
421
422 if not installed:
423 print("Installing service...")
424 rc = _install(port=port)
425 if rc != 0:
426 return rc
427
428 if platform == "darwin":
429 uid = os.getuid()
430 result = subprocess.run(
431 ["launchctl", "print", f"gui/{uid}/{SERVICE_LABEL}"],
432 capture_output=True,
433 text=True,
434 )
435 running = result.returncode == 0
436 else:
437 result = subprocess.run(
438 ["systemctl", "--user", "is-active", SYSTEMD_UNIT],
439 capture_output=True,
440 text=True,
441 )
442 running = result.stdout.strip() == "active"
443
444 if not running:
445 print("Starting service...")
446 rc = _start()
447 if rc != 0:
448 return rc
449
450 return _status()
451
452
453def _down() -> int:
454 """Stop the service."""
455 return _stop()
456
457
458_SUBCOMMANDS = {
459 "uninstall": _uninstall,
460 "start": _start,
461 "stop": _stop,
462 "status": _status,
463 "down": lambda **_kw: _down(),
464}
465
466
467def _parse_port(args: list[str]) -> int:
468 """Extract --port PORT from args, return DEFAULT_SERVICE_PORT if absent."""
469 for i, arg in enumerate(args):
470 if arg == "--port" and i + 1 < len(args):
471 try:
472 return int(args[i + 1])
473 except ValueError:
474 print(f"Error: invalid port '{args[i + 1]}'", file=sys.stderr)
475 sys.exit(1)
476 if arg.startswith("--port="):
477 try:
478 return int(arg.split("=", 1)[1])
479 except ValueError:
480 print(f"Error: invalid port '{arg}'", file=sys.stderr)
481 sys.exit(1)
482 return DEFAULT_SERVICE_PORT
483
484
485def main() -> None:
486 """Entry point for ``sol service``."""
487 args = sys.argv[1:]
488
489 if args and args[0] == "logs":
490 follow = "-f" in args[1:] or "--follow" in args[1:]
491 sys.exit(_logs(follow=follow))
492
493 if not args:
494 print("Usage: sol service <install|uninstall|start|stop|restart|status|logs>")
495 print(" sol service install [--port PORT] (default: 5015)")
496 print(
497 " sol service restart [--if-installed] "
498 "(restart; --if-installed noops if not installed)"
499 )
500 print(" sol up [--port PORT] (install + start + status)")
501 print(" sol down (stop)")
502 sys.exit(1)
503
504 subcmd = args[0]
505 rest = args[1:]
506
507 if subcmd == "install":
508 sys.exit(_install(port=_parse_port(rest)))
509 elif subcmd == "up":
510 sys.exit(_up(port=_parse_port(rest)))
511 elif subcmd == "restart":
512 if_installed = "--if-installed" in rest
513 sys.exit(_restart(if_installed=if_installed))
514 elif subcmd in _SUBCOMMANDS:
515 sys.exit(_SUBCOMMANDS[subcmd]())
516 else:
517 print(f"Unknown subcommand: {subcmd}", file=sys.stderr)
518 print("Available: install, uninstall, start, stop, restart, status, logs")
519 sys.exit(1)