delimit-cli 4.1.53 → 4.2.0

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.
@@ -1,462 +0,0 @@
1
- """
2
- Continuity State Resolution (LED-240).
3
-
4
- Resolves user identity, project, venture, and private state namespace
5
- at startup so all tools bind to the correct local state under ~/.delimit/.
6
-
7
- Design:
8
- - Single-user (root) today: namespace collapses to ~/.delimit/ (backwards compat)
9
- - Multi-user (future): each user gets ~/.delimit/users/{user_hash}/
10
- - Private state never leaks into git-tracked dirs, npm payloads, or public repos
11
-
12
- Resolution chain:
13
- resolve_user() -> whoami + git config + gh auth
14
- resolve_project() -> git remote + .delimit/ dir + package.json/pyproject.toml
15
- resolve_venture() -> map project to venture via ventures.json registry
16
- resolve_namespace() -> compute private state path
17
- auto_bind() -> run all, set env vars for downstream tools
18
- """
19
-
20
- import hashlib
21
- import json
22
- import logging
23
- import os
24
- import subprocess
25
- from pathlib import Path
26
- from typing import Any, Dict, Optional
27
-
28
- logger = logging.getLogger("delimit.continuity")
29
-
30
- DELIMIT_HOME = Path.home() / ".delimit"
31
- VENTURES_FILE = DELIMIT_HOME / "ventures.json"
32
-
33
- # Directories that hold private continuity state (must never be git-tracked or published)
34
- PRIVATE_STATE_DIRS = [
35
- "souls",
36
- "handoff_receipts",
37
- "ledger",
38
- "evidence",
39
- "agent_actions",
40
- "events",
41
- "traces",
42
- "deliberations",
43
- "audit",
44
- "audits",
45
- "vault",
46
- "secrets",
47
- "credentials",
48
- "context",
49
- "continuity",
50
- ]
51
-
52
-
53
- def _run_cmd(args: list, timeout: int = 5) -> str:
54
- """Run a command and return stdout, or empty string on failure."""
55
- try:
56
- result = subprocess.run(
57
- args,
58
- capture_output=True,
59
- text=True,
60
- timeout=timeout,
61
- )
62
- if result.returncode == 0:
63
- return result.stdout.strip()
64
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
65
- pass
66
- return ""
67
-
68
-
69
- def _stable_hash(value: str) -> str:
70
- """Deterministic short hash for namespace isolation."""
71
- return hashlib.sha256(value.encode()).hexdigest()[:16]
72
-
73
-
74
- # ---- resolve_user -------------------------------------------------------
75
-
76
- def resolve_user() -> Dict[str, str]:
77
- """Detect the current user from OS, git config, and GitHub auth.
78
-
79
- Returns a dict with:
80
- - os_user: system username (whoami)
81
- - git_email: git config user.email (may be empty)
82
- - git_name: git config user.name (may be empty)
83
- - gh_user: GitHub username from gh auth status (may be empty)
84
- - user_hash: stable hash derived from the best available identity
85
- - identity_source: which signal produced the hash
86
- """
87
- os_user = os.environ.get("USER", "") or _run_cmd(["whoami"])
88
- git_email = _run_cmd(["git", "config", "--global", "user.email"])
89
- git_name = _run_cmd(["git", "config", "--global", "user.name"])
90
-
91
- # gh auth status --active outputs the logged-in user
92
- gh_user = ""
93
- gh_raw = _run_cmd(["gh", "auth", "status"])
94
- if gh_raw:
95
- # Parse "Logged in to github.com account <user> ..."
96
- for line in gh_raw.splitlines():
97
- stripped = line.strip()
98
- if "account" in stripped.lower():
99
- parts = stripped.split()
100
- for i, tok in enumerate(parts):
101
- if tok.lower() == "account" and i + 1 < len(parts):
102
- candidate = parts[i + 1].strip("()")
103
- if candidate and candidate not in ("as", "to"):
104
- gh_user = candidate
105
- break
106
- if gh_user:
107
- break
108
-
109
- # Pick the strongest identity signal for hashing
110
- if gh_user:
111
- identity_key = f"gh:{gh_user}"
112
- identity_source = "github"
113
- elif git_email:
114
- identity_key = f"email:{git_email}"
115
- identity_source = "git_email"
116
- elif os_user:
117
- identity_key = f"os:{os_user}"
118
- identity_source = "os_user"
119
- else:
120
- identity_key = "unknown"
121
- identity_source = "none"
122
-
123
- return {
124
- "os_user": os_user,
125
- "git_email": git_email,
126
- "git_name": git_name,
127
- "gh_user": gh_user,
128
- "user_hash": _stable_hash(identity_key),
129
- "identity_source": identity_source,
130
- }
131
-
132
-
133
- # ---- resolve_project -----------------------------------------------------
134
-
135
- def resolve_project(project_path: str = ".") -> Dict[str, str]:
136
- """Detect the current project from the working directory.
137
-
138
- Returns a dict with:
139
- - path: resolved absolute path
140
- - name: project name (from package.json, pyproject.toml, or dir name)
141
- - repo_url: git remote origin URL (may be empty)
142
- - project_hash: stable hash of the resolved path
143
- - has_delimit_dir: whether .delimit/ exists in the project
144
- - project_type: node | python | unknown
145
- """
146
- p = Path(project_path).resolve()
147
- info: Dict[str, str] = {
148
- "path": str(p),
149
- "name": p.name,
150
- "repo_url": "",
151
- "project_hash": _stable_hash(str(p)),
152
- "has_delimit_dir": str((p / ".delimit").is_dir()),
153
- "project_type": "unknown",
154
- }
155
-
156
- # package.json
157
- pkg_file = p / "package.json"
158
- if pkg_file.exists():
159
- try:
160
- pkg = json.loads(pkg_file.read_text())
161
- info["name"] = pkg.get("name", p.name)
162
- info["project_type"] = "node"
163
- except Exception:
164
- pass
165
-
166
- # pyproject.toml
167
- pyproj = p / "pyproject.toml"
168
- if pyproj.exists() and info["project_type"] == "unknown":
169
- try:
170
- for line in pyproj.read_text().splitlines():
171
- if line.strip().startswith("name"):
172
- val = line.split("=", 1)[1].strip().strip('"').strip("'")
173
- if val:
174
- info["name"] = val
175
- info["project_type"] = "python"
176
- break
177
- except Exception:
178
- pass
179
-
180
- # git remote
181
- remote = _run_cmd(["git", "-C", str(p), "remote", "get-url", "origin"])
182
- if remote:
183
- info["repo_url"] = remote
184
-
185
- return info
186
-
187
-
188
- # ---- resolve_venture -----------------------------------------------------
189
-
190
- def resolve_venture(project_path: str = ".") -> Dict[str, str]:
191
- """Map a project to its venture using the global ventures registry.
192
-
193
- Returns a dict with:
194
- - venture_name: matched venture name (or "unregistered")
195
- - venture_match: how the match was made (exact | path | repo | none)
196
- - registered_ventures: count of ventures in registry
197
- """
198
- project = resolve_project(project_path)
199
- resolved_path = project["path"]
200
- repo_url = project["repo_url"]
201
-
202
- ventures = _load_ventures()
203
- result: Dict[str, str] = {
204
- "venture_name": "unregistered",
205
- "venture_match": "none",
206
- "registered_ventures": str(len(ventures)),
207
- }
208
-
209
- # Exact path match
210
- for name, vinfo in ventures.items():
211
- v_path = vinfo.get("path", "")
212
- if v_path and os.path.realpath(v_path) == os.path.realpath(resolved_path):
213
- result["venture_name"] = name
214
- result["venture_match"] = "exact"
215
- return result
216
-
217
- # Path-prefix match (project is a subdirectory of a venture)
218
- for name, vinfo in ventures.items():
219
- v_path = vinfo.get("path", "")
220
- if v_path:
221
- try:
222
- if Path(resolved_path).is_relative_to(Path(v_path).resolve()):
223
- result["venture_name"] = name
224
- result["venture_match"] = "path"
225
- return result
226
- except (ValueError, TypeError):
227
- pass
228
-
229
- # Repo URL match
230
- if repo_url:
231
- normalized_repo = repo_url.rstrip("/").replace(".git", "").lower()
232
- for name, vinfo in ventures.items():
233
- v_repo = vinfo.get("repo", "").rstrip("/").replace(".git", "").lower()
234
- if v_repo and v_repo == normalized_repo:
235
- result["venture_name"] = name
236
- result["venture_match"] = "repo"
237
- return result
238
-
239
- return result
240
-
241
-
242
- def _load_ventures() -> Dict[str, Any]:
243
- """Load the global ventures registry."""
244
- if not VENTURES_FILE.exists():
245
- return {}
246
- try:
247
- return json.loads(VENTURES_FILE.read_text())
248
- except (json.JSONDecodeError, OSError):
249
- return {}
250
-
251
-
252
- # ---- resolve_namespace ---------------------------------------------------
253
-
254
- def resolve_namespace(
255
- user_hash: str = "",
256
- venture_name: str = "",
257
- project_hash: str = "",
258
- ) -> Dict[str, str]:
259
- """Compute the private state path for a user/venture/project combination.
260
-
261
- Namespace layout:
262
- Single-user (default): ~/.delimit/ (backwards compat)
263
- Multi-user (future): ~/.delimit/users/{user_hash}/
264
-
265
- Within a namespace, state is organized by type:
266
- souls/{project_hash}/
267
- handoff_receipts/{project_hash}/
268
- ledger/
269
- evidence/
270
- ...
271
-
272
- Returns a dict with:
273
- - namespace_root: the base path for this user's private state
274
- - is_multi_user: whether multi-user scoping is active
275
- - souls_dir: where soul captures go
276
- - receipts_dir: where handoff receipts go
277
- - ledger_dir: central ledger
278
- - evidence_dir: audit evidence
279
- - events_dir: event log
280
- """
281
- # Determine if we are in multi-user mode.
282
- # For now, multi-user activates only if DELIMIT_MULTI_USER=1 is set,
283
- # keeping backwards compat for the single-user (root) case.
284
- multi_user = os.environ.get("DELIMIT_MULTI_USER", "") == "1"
285
-
286
- if multi_user and user_hash:
287
- namespace_root = DELIMIT_HOME / "users" / user_hash
288
- else:
289
- namespace_root = DELIMIT_HOME
290
-
291
- result = {
292
- "namespace_root": str(namespace_root),
293
- "is_multi_user": str(multi_user),
294
- "souls_dir": str(namespace_root / "souls"),
295
- "receipts_dir": str(namespace_root / "handoff_receipts"),
296
- "ledger_dir": str(namespace_root / "ledger"),
297
- "evidence_dir": str(namespace_root / "evidence"),
298
- "events_dir": str(namespace_root / "events"),
299
- "traces_dir": str(namespace_root / "traces"),
300
- "agent_actions_dir": str(namespace_root / "agent_actions"),
301
- }
302
-
303
- # If project_hash is provided, the per-project subdirs get it
304
- if project_hash:
305
- result["souls_dir"] = str(namespace_root / "souls" / project_hash)
306
- result["receipts_dir"] = str(
307
- namespace_root / "handoff_receipts" / project_hash
308
- )
309
-
310
- return result
311
-
312
-
313
- # ---- auto_bind -----------------------------------------------------------
314
-
315
- def auto_bind(project_path: str = ".") -> Dict[str, Any]:
316
- """Run full resolution chain and set environment variables.
317
-
318
- This is the single entry point for startup. It:
319
- 1. Resolves user identity
320
- 2. Resolves current project
321
- 3. Maps project to venture
322
- 4. Computes the namespace
323
- 5. Sets DELIMIT_* env vars so all downstream tools use the right paths
324
- 6. Ensures the namespace directories exist
325
-
326
- Returns the full resolution context for logging/debugging.
327
- """
328
- user = resolve_user()
329
- project = resolve_project(project_path)
330
- venture = resolve_venture(project_path)
331
- namespace = resolve_namespace(
332
- user_hash=user["user_hash"],
333
- venture_name=venture["venture_name"],
334
- project_hash=project["project_hash"],
335
- )
336
-
337
- # Set env vars for downstream consumption
338
- _env_vars = {
339
- "DELIMIT_USER_HASH": user["user_hash"],
340
- "DELIMIT_USER_IDENTITY": user.get("gh_user") or user.get("git_email") or user.get("os_user", ""),
341
- "DELIMIT_PROJECT_PATH": project["path"],
342
- "DELIMIT_PROJECT_HASH": project["project_hash"],
343
- "DELIMIT_PROJECT_NAME": project["name"],
344
- "DELIMIT_VENTURE": venture["venture_name"],
345
- "DELIMIT_NAMESPACE_ROOT": namespace["namespace_root"],
346
- "DELIMIT_LEDGER_DIR": namespace["ledger_dir"],
347
- "DELIMIT_SOULS_DIR": namespace["souls_dir"],
348
- "DELIMIT_EVIDENCE_DIR": namespace["evidence_dir"],
349
- }
350
-
351
- for key, value in _env_vars.items():
352
- os.environ[key] = value
353
-
354
- # Ensure critical namespace directories exist
355
- for dir_key in ("namespace_root", "souls_dir", "receipts_dir", "ledger_dir",
356
- "evidence_dir", "events_dir", "traces_dir", "agent_actions_dir"):
357
- Path(namespace[dir_key]).mkdir(parents=True, exist_ok=True)
358
-
359
- # Verify private state is not inside a git worktree that would be committed
360
- leak_warnings = _check_for_leaks(namespace["namespace_root"])
361
-
362
- context = {
363
- "user": user,
364
- "project": project,
365
- "venture": venture,
366
- "namespace": namespace,
367
- "env_vars_set": list(_env_vars.keys()),
368
- "leak_warnings": leak_warnings,
369
- }
370
-
371
- logger.info(
372
- "Continuity bound: user=%s project=%s venture=%s namespace=%s",
373
- user.get("gh_user") or user.get("os_user", "?"),
374
- project["name"],
375
- venture["venture_name"],
376
- namespace["namespace_root"],
377
- )
378
-
379
- return context
380
-
381
-
382
- # ---- Safety checks -------------------------------------------------------
383
-
384
- def _check_for_leaks(namespace_root: str) -> list:
385
- """Check that the namespace root is not inside a git worktree or npm package.
386
-
387
- Returns a list of warning strings (empty if clean).
388
- """
389
- warnings = []
390
- ns_path = Path(namespace_root).resolve()
391
-
392
- # Check 1: namespace should be under ~/.delimit/ (home directory)
393
- home = Path.home().resolve()
394
- expected_base = home / ".delimit"
395
- if not str(ns_path).startswith(str(expected_base)):
396
- warnings.append(
397
- f"Namespace root {ns_path} is outside ~/.delimit/ -- state may leak"
398
- )
399
-
400
- # Check 2: namespace should not be inside a git worktree
401
- git_root = _run_cmd(["git", "-C", str(ns_path), "rev-parse", "--show-toplevel"])
402
- if git_root:
403
- # If the git root is the home dir itself, that is fine (some users have ~/ as a repo)
404
- # But if it is a project repo, that is a problem.
405
- git_root_path = Path(git_root).resolve()
406
- if git_root_path != home and str(ns_path).startswith(str(git_root_path)):
407
- warnings.append(
408
- f"Namespace root {ns_path} is inside git worktree {git_root_path} "
409
- "-- private state could be committed. Add to .gitignore."
410
- )
411
-
412
- # Check 3: verify .gitignore coverage in the gateway repo
413
- gateway_gitignore = Path("/home/delimit/delimit-gateway/.gitignore")
414
- if gateway_gitignore.exists():
415
- content = gateway_gitignore.read_text()
416
- if ".delimit/" not in content and ".delimit/ledger/" not in content:
417
- warnings.append(
418
- "Gateway .gitignore does not exclude .delimit/ -- state may be committed"
419
- )
420
-
421
- return warnings
422
-
423
-
424
- def verify_npm_exclusion() -> Dict[str, Any]:
425
- """Verify that private state directories are excluded from npm publish.
426
-
427
- Checks .npmignore in the npm package for coverage of state dirs.
428
- Returns a report.
429
- """
430
- npmignore = Path("/home/delimit/npm-delimit/.npmignore")
431
- result: Dict[str, Any] = {
432
- "npmignore_exists": npmignore.exists(),
433
- "covered": [],
434
- "missing": [],
435
- }
436
-
437
- if not npmignore.exists():
438
- result["missing"] = PRIVATE_STATE_DIRS[:]
439
- return result
440
-
441
- content = npmignore.read_text()
442
- for dirname in PRIVATE_STATE_DIRS:
443
- # Check if the dir or a pattern covering it appears in .npmignore
444
- if dirname in content or ".delimit" in content or f"**/{dirname}" in content:
445
- result["covered"].append(dirname)
446
- else:
447
- result["missing"].append(dirname)
448
-
449
- return result
450
-
451
-
452
- # ---- Convenience for other modules ---------------------------------------
453
-
454
- def get_namespace_root() -> Path:
455
- """Return the current namespace root from env or default.
456
-
457
- Other modules can call this instead of hardcoding Path.home() / ".delimit".
458
- """
459
- env_root = os.environ.get("DELIMIT_NAMESPACE_ROOT", "")
460
- if env_root:
461
- return Path(env_root)
462
- return DELIMIT_HOME
@@ -1,217 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Standalone runner for the Delimit inbox polling daemon.
4
-
5
- Designed for use with systemd or manual invocation. Adds:
6
- - Structured logging with timestamps
7
- - Graceful SIGTERM handling for clean systemd stop
8
- - PID file to prevent duplicate instances
9
- - Startup validation of required configuration
10
-
11
- Usage:
12
- # Via systemd (see deploy/inbox-daemon.service)
13
- systemctl start delimit-inbox-daemon
14
-
15
- # Manual foreground run
16
- python3 ai/inbox_daemon_runner.py
17
-
18
- # Single poll cycle (for testing)
19
- python3 ai/inbox_daemon_runner.py --once
20
-
21
- Environment variables:
22
- DELIMIT_SMTP_PASS Required. IMAP/SMTP password.
23
- DELIMIT_INBOX_POLL_INTERVAL Poll interval in seconds (default: 300).
24
- DELIMIT_HOME Delimit config directory (default: ~/.delimit).
25
- PYTHONPATH Must include the gateway root for ai.* imports.
26
- """
27
-
28
- import logging
29
- import os
30
- import signal
31
- import sys
32
- import time
33
- from datetime import datetime, timezone
34
- from pathlib import Path
35
-
36
- # Ensure the gateway root is on sys.path so ai.* imports work
37
- _gateway_root = Path(__file__).resolve().parent.parent
38
- if str(_gateway_root) not in sys.path:
39
- sys.path.insert(0, str(_gateway_root))
40
-
41
- # PID file to prevent duplicate instances
42
- PID_DIR = Path(os.environ.get("DELIMIT_HOME", Path.home() / ".delimit"))
43
- PID_FILE = PID_DIR / "inbox-daemon.pid"
44
-
45
-
46
- def _setup_logging() -> logging.Logger:
47
- """Configure structured logging for journald and console."""
48
- log_format = "%(asctime)s [%(name)s] %(levelname)s: %(message)s"
49
- logging.basicConfig(
50
- level=logging.INFO,
51
- format=log_format,
52
- stream=sys.stdout,
53
- )
54
- # Suppress noisy libraries
55
- logging.getLogger("urllib3").setLevel(logging.WARNING)
56
- logging.getLogger("imaplib").setLevel(logging.WARNING)
57
- return logging.getLogger("delimit.inbox_daemon_runner")
58
-
59
-
60
- def _write_pid() -> None:
61
- """Write PID file. Check for stale processes first."""
62
- PID_DIR.mkdir(parents=True, exist_ok=True)
63
-
64
- if PID_FILE.exists():
65
- try:
66
- old_pid = int(PID_FILE.read_text().strip())
67
- # Check if the old process is still running
68
- os.kill(old_pid, 0)
69
- # Process exists -- abort to prevent duplicates
70
- print(
71
- f"ERROR: Another inbox daemon is running (PID {old_pid}). "
72
- f"Remove {PID_FILE} if stale.",
73
- file=sys.stderr,
74
- )
75
- sys.exit(1)
76
- except (ValueError, ProcessLookupError, PermissionError):
77
- # Stale PID file -- safe to overwrite
78
- pass
79
- except OSError:
80
- pass
81
-
82
- PID_FILE.write_text(str(os.getpid()))
83
-
84
-
85
- def _remove_pid() -> None:
86
- """Remove PID file on clean shutdown."""
87
- try:
88
- if PID_FILE.exists():
89
- current_pid = PID_FILE.read_text().strip()
90
- if current_pid == str(os.getpid()):
91
- PID_FILE.unlink()
92
- except OSError:
93
- pass
94
-
95
-
96
- def _validate_config(logger: logging.Logger) -> bool:
97
- """Validate required configuration before starting the daemon."""
98
- ok = True
99
-
100
- if not os.environ.get("DELIMIT_SMTP_PASS"):
101
- # Check if the notify module can load credentials from config
102
- try:
103
- from ai.notify import _load_smtp_account, IMAP_USER
104
- if IMAP_USER:
105
- account = _load_smtp_account(IMAP_USER)
106
- if account and (account.get("pass") or account.get("password")):
107
- logger.info("SMTP credentials loaded from config for %s", IMAP_USER)
108
- else:
109
- logger.error(
110
- "DELIMIT_SMTP_PASS not set and no credentials found in config for %s",
111
- IMAP_USER,
112
- )
113
- ok = False
114
- else:
115
- logger.error("DELIMIT_SMTP_PASS not set and IMAP_USER not configured")
116
- ok = False
117
- except ImportError:
118
- logger.error("DELIMIT_SMTP_PASS not set and ai.notify module not importable")
119
- ok = False
120
- else:
121
- logger.info("SMTP credentials provided via environment")
122
-
123
- return ok
124
-
125
-
126
- def main() -> None:
127
- import argparse
128
-
129
- parser = argparse.ArgumentParser(
130
- description="Delimit inbox daemon runner -- persistent email governance polling",
131
- )
132
- parser.add_argument(
133
- "--once",
134
- action="store_true",
135
- help="Run a single poll cycle and exit",
136
- )
137
- parser.add_argument(
138
- "--interval",
139
- type=int,
140
- default=None,
141
- help="Override poll interval in seconds",
142
- )
143
- args = parser.parse_args()
144
-
145
- logger = _setup_logging()
146
- logger.info(
147
- "Delimit inbox daemon runner starting (PID %d, Python %s)",
148
- os.getpid(),
149
- sys.version.split()[0],
150
- )
151
-
152
- # Validate config before doing anything else
153
- if not _validate_config(logger):
154
- logger.error("Configuration validation failed. Exiting.")
155
- sys.exit(1)
156
-
157
- # Import the daemon module (after PYTHONPATH is set up)
158
- from ai.inbox_daemon import (
159
- _daemon_state,
160
- _daemon_loop,
161
- poll_once,
162
- POLL_INTERVAL,
163
- )
164
-
165
- # Override poll interval if requested
166
- if args.interval is not None:
167
- import ai.inbox_daemon
168
- ai.inbox_daemon.POLL_INTERVAL = args.interval
169
- logger.info("Poll interval overridden to %d seconds", args.interval)
170
-
171
- # Single-shot mode
172
- if args.once:
173
- logger.info("Running single poll cycle (--once mode)")
174
- result = poll_once()
175
- if "error" in result:
176
- logger.error("Poll failed: %s", result["error"])
177
- sys.exit(1)
178
- logger.info(
179
- "Poll complete: %d processed, %d forwarded",
180
- result.get("processed", 0),
181
- result.get("forwarded", 0),
182
- )
183
- return
184
-
185
- # Write PID file (only for long-running mode)
186
- _write_pid()
187
-
188
- # Graceful shutdown handler
189
- def _handle_signal(signum, frame):
190
- sig_name = signal.Signals(signum).name
191
- logger.info("Received %s -- initiating graceful shutdown", sig_name)
192
- _daemon_state._stop_event.set()
193
-
194
- signal.signal(signal.SIGTERM, _handle_signal)
195
- signal.signal(signal.SIGINT, _handle_signal)
196
-
197
- # Start the daemon loop (blocks until stop event)
198
- logger.info(
199
- "Inbox daemon entering main loop (poll interval: %ds)",
200
- ai.inbox_daemon.POLL_INTERVAL,
201
- )
202
- _daemon_state.running = True
203
- _daemon_state._stop_event.clear()
204
-
205
- try:
206
- _daemon_loop()
207
- except Exception as e:
208
- logger.critical("Daemon loop crashed: %s", e, exc_info=True)
209
- sys.exit(1)
210
- finally:
211
- _daemon_state.running = False
212
- _remove_pid()
213
- logger.info("Inbox daemon runner exiting cleanly")
214
-
215
-
216
- if __name__ == "__main__":
217
- main()