neruva-control 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.
@@ -0,0 +1,22 @@
1
+ """Neruva Control -- the local daemon behind Neruva Cockpit.
2
+
3
+ The browser at app.neruva.io connects to this daemon over a loopback
4
+ WebSocket (localhost:7331) to spawn and tail Claude Code sessions on
5
+ the user's machine. Every session's events are auto-recorded into the
6
+ Neruva substrate so the agent has a brain that survives across runs.
7
+
8
+ Install: ``pip install neruva-control && neruva-control-install``
9
+
10
+ After install, open https://app.neruva.io/cockpit -- the install
11
+ script prints a one-time URL with your machine's auth token.
12
+
13
+ Public surface for library users (rare; most users only need the CLI):
14
+
15
+ from neruva_control import daemon # FastAPI app
16
+ from neruva_control._sessions import SessionManager
17
+ """
18
+ from __future__ import annotations
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = ["__version__"]
neruva_control/_cli.py ADDED
@@ -0,0 +1,163 @@
1
+ """CLI entry-point for `neruva-control`.
2
+
3
+ Subcommands:
4
+ start -- run the daemon in the foreground (blocks). Used by the OS
5
+ service registration. Power users can run it manually.
6
+ stop -- find a running daemon and SIGTERM it.
7
+ status -- print health: token present? daemon listening? PID?
8
+ link -- print the fragment URL again (for re-link or new browser).
9
+ serve -- alias for start (matches `uvicorn` muscle memory).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import signal
15
+ import socket
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from . import __version__
21
+ from ._config import config_dir, cockpit_link_url, load_token, token_path
22
+
23
+
24
+ PORT = 7331
25
+
26
+
27
+ def _is_listening(host: str = "127.0.0.1", port: int = PORT, timeout: float = 0.5) -> bool:
28
+ """True if something is bound to host:port. Used by `status` and by
29
+ install's "is the service already up" check."""
30
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
31
+ s.settimeout(timeout)
32
+ try:
33
+ s.connect((host, port))
34
+ s.close()
35
+ return True
36
+ except OSError:
37
+ return False
38
+
39
+
40
+ def _cmd_start(args: argparse.Namespace) -> int:
41
+ from .daemon import serve
42
+ host = args.bind or "127.0.0.1"
43
+ port = args.port or PORT
44
+ if host != "127.0.0.1":
45
+ print(
46
+ f"[neruva-control] WARNING: binding on {host}:{port} -- this exposes the daemon\n"
47
+ f" beyond loopback. The auth token is the only thing protecting the API.\n"
48
+ f" Recommended for remote access: Tailscale Serve over loopback instead.\n",
49
+ file=sys.stderr,
50
+ )
51
+ serve(host=host, port=port)
52
+ return 0
53
+
54
+
55
+ def _cmd_status(_args: argparse.Namespace) -> int:
56
+ has_token = token_path().exists()
57
+ listening = _is_listening()
58
+ print(f"neruva-control {__version__}")
59
+ print(f" config dir : {config_dir()}")
60
+ print(f" token : {'present' if has_token else 'MISSING (run neruva-control-install)'}")
61
+ print(f" daemon : {'listening on 127.0.0.1:' + str(PORT) if listening else 'NOT running'}")
62
+ if has_token:
63
+ try:
64
+ token = load_token()
65
+ print(f" link URL : {cockpit_link_url(token)}")
66
+ except Exception:
67
+ pass
68
+ return 0 if (has_token and listening) else 1
69
+
70
+
71
+ def _cmd_link(_args: argparse.Namespace) -> int:
72
+ try:
73
+ token = load_token()
74
+ except FileNotFoundError as e:
75
+ print(str(e), file=sys.stderr)
76
+ return 1
77
+ print()
78
+ print("Open this URL in your browser to link this machine to Cockpit:")
79
+ print()
80
+ print(f" {cockpit_link_url(token)}")
81
+ print()
82
+ print("(Token will be stashed in your browser's localStorage; the URL")
83
+ print(" fragment is stripped after first load. Same machine, same browser.)")
84
+ return 0
85
+
86
+
87
+ def _cmd_stop(_args: argparse.Namespace) -> int:
88
+ """Best-effort daemon stop. Reads PID from pidfile if present;
89
+ falls back to telling user to kill it themselves on weird OSes."""
90
+ pidfile = config_dir() / "control.pid"
91
+ if not pidfile.exists():
92
+ print("No pidfile -- daemon not started by neruva-control or already stopped.")
93
+ return 0 if not _is_listening() else 1
94
+ try:
95
+ pid = int(pidfile.read_text().strip())
96
+ except Exception:
97
+ print(f"Bad pidfile at {pidfile}; remove it manually.", file=sys.stderr)
98
+ return 1
99
+ try:
100
+ if sys.platform == "win32":
101
+ import ctypes
102
+ PROCESS_TERMINATE = 1
103
+ handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
104
+ if handle:
105
+ ctypes.windll.kernel32.TerminateProcess(handle, 0)
106
+ ctypes.windll.kernel32.CloseHandle(handle)
107
+ else:
108
+ import os as _os
109
+ _os.kill(pid, signal.SIGTERM)
110
+ # Wait briefly for socket to free
111
+ for _ in range(20):
112
+ if not _is_listening():
113
+ break
114
+ time.sleep(0.1)
115
+ try: pidfile.unlink()
116
+ except Exception: pass
117
+ print(f"Stopped daemon (pid {pid}).")
118
+ return 0
119
+ except ProcessLookupError:
120
+ print("Daemon process already gone.")
121
+ try: pidfile.unlink()
122
+ except Exception: pass
123
+ return 0
124
+ except Exception as e:
125
+ print(f"Failed to stop pid {pid}: {e}", file=sys.stderr)
126
+ return 1
127
+
128
+
129
+ def main(argv: list[str] | None = None) -> int:
130
+ parser = argparse.ArgumentParser(
131
+ prog="neruva-control",
132
+ description="Local controller daemon for Neruva Cockpit.",
133
+ )
134
+ parser.add_argument("--version", action="version", version=__version__)
135
+ sub = parser.add_subparsers(dest="cmd", required=True)
136
+ p_start = sub.add_parser("start", help="Run the daemon (foreground; blocks).")
137
+ p_start.add_argument("--bind", default=None,
138
+ help="Bind address (default 127.0.0.1, loopback only). Pass "
139
+ "0.0.0.0 to expose on LAN -- only do this if you trust "
140
+ "your network. For remote access prefer Tailscale Serve.")
141
+ p_start.add_argument("--port", type=int, default=None, help=f"Port (default {PORT})")
142
+ p_serve = sub.add_parser("serve", help="Alias for start.")
143
+ p_serve.add_argument("--bind", default=None)
144
+ p_serve.add_argument("--port", type=int, default=None)
145
+ sub.add_parser("status", help="Show install + daemon status.")
146
+ sub.add_parser("link", help="Print the browser link URL.")
147
+ sub.add_parser("stop", help="Stop the daemon.")
148
+ args = parser.parse_args(argv)
149
+ handler = {
150
+ "start": _cmd_start,
151
+ "serve": _cmd_start,
152
+ "status": _cmd_status,
153
+ "link": _cmd_link,
154
+ "stop": _cmd_stop,
155
+ }.get(args.cmd)
156
+ if not handler:
157
+ parser.print_help()
158
+ return 2
159
+ return handler(args)
160
+
161
+
162
+ if __name__ == "__main__":
163
+ sys.exit(main())
@@ -0,0 +1,85 @@
1
+ """Config + token I/O for neruva-control.
2
+
3
+ Single source of truth for where files live (cross-platform via
4
+ platformdirs). The token is the shared secret between the daemon
5
+ process and the browser at app.neruva.io -- generated once at
6
+ install, never rotated unless the user explicitly re-installs.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import secrets
12
+ from pathlib import Path
13
+ from platformdirs import user_config_dir
14
+
15
+
16
+ CONFIG_APP = "neruva"
17
+ CONFIG_AUTHOR = "neruva"
18
+
19
+ # Default substrate base. Overridable via NERUVA_API_BASE env (or future
20
+ # control.toml setting) for self-hosted / staging.
21
+ DEFAULT_API_BASE = "https://api.neruva.io"
22
+
23
+
24
+ def neruva_api_base() -> str:
25
+ return os.environ.get("NERUVA_API_BASE", DEFAULT_API_BASE)
26
+
27
+
28
+ def neruva_api_key() -> str | None:
29
+ """User's Neruva API key for posting recorded events. Read from env
30
+ first, then ~/.config/neruva/api.key as a stable per-machine option.
31
+ Returns None if unset -- daemon then runs in 'no-record' mode."""
32
+ k = os.environ.get("NERUVA_API_KEY")
33
+ if k:
34
+ return k.strip()
35
+ p = config_dir() / "api.key"
36
+ if p.exists():
37
+ try:
38
+ return p.read_text(encoding="utf-8").strip() or None
39
+ except Exception:
40
+ return None
41
+ return None
42
+
43
+
44
+ def config_dir() -> Path:
45
+ p = Path(user_config_dir(CONFIG_APP, CONFIG_AUTHOR))
46
+ p.mkdir(parents=True, exist_ok=True)
47
+ return p
48
+
49
+
50
+ def token_path() -> Path:
51
+ return config_dir() / "control.token"
52
+
53
+
54
+ def config_path() -> Path:
55
+ return config_dir() / "control.toml"
56
+
57
+
58
+ def load_token() -> str:
59
+ """Read the shared token. Raises FileNotFoundError if not installed."""
60
+ p = token_path()
61
+ if not p.exists():
62
+ raise FileNotFoundError(
63
+ f"No token at {p}. Run `neruva-control-install` first."
64
+ )
65
+ return p.read_text(encoding="utf-8").strip()
66
+
67
+
68
+ def write_token(token: str) -> Path:
69
+ """Write a new token (overwrite). Returns the path."""
70
+ p = token_path()
71
+ p.write_text(token, encoding="utf-8")
72
+ # chmod 600 on Unix; on Windows ACLs already restrict to user
73
+ if os.name == "posix":
74
+ os.chmod(p, 0o600)
75
+ return p
76
+
77
+
78
+ def gen_token() -> str:
79
+ return secrets.token_urlsafe(32)
80
+
81
+
82
+ def cockpit_link_url(token: str, base: str = "https://app.neruva.io") -> str:
83
+ """Fragment URL the installer prints. Browser reads #token=..., stashes
84
+ in localStorage, then strips via history.replaceState."""
85
+ return f"{base}/cockpit#token={token}"
@@ -0,0 +1,243 @@
1
+ """`neruva-control-install` -- the one-shot install command.
2
+
3
+ Goals (Kyle: "make sure UX is world class"):
4
+ - User runs `pip install neruva-control && neruva-control-install`
5
+ - We generate a token, write config, register an OS service, start
6
+ the daemon, and print a clickable URL with the token in the
7
+ fragment so the browser can link in one click.
8
+ - No flags needed in the common path. `--help` documents the rare
9
+ overrides (--no-service, --reinstall, --port).
10
+
11
+ Cross-platform service registration:
12
+ - macOS : ~/Library/LaunchAgents/io.neruva.control.plist + launchctl load
13
+ - Linux : ~/.config/systemd/user/neruva-control.service + systemctl --user
14
+ - Windows: schtasks /Create with ONLOGON trigger + immediate start
15
+
16
+ If service registration fails (rootless containers, weird init systems),
17
+ we degrade to "you're set up, but you'll need to run `neruva-control
18
+ start` yourself in a background shell".
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import os
24
+ import shutil
25
+ import socket
26
+ import subprocess
27
+ import sys
28
+ import textwrap
29
+ import time
30
+ from pathlib import Path
31
+
32
+ from ._config import config_dir, gen_token, write_token, cockpit_link_url, token_path
33
+
34
+
35
+ PORT = 7331
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Cross-platform service registration
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def _python_exe() -> str:
43
+ return sys.executable or "python"
44
+
45
+
46
+ def _start_cmd() -> list[str]:
47
+ """The command that runs the daemon foreground. Used by service
48
+ registration on every OS."""
49
+ return [_python_exe(), "-m", "neruva_control._cli", "start"]
50
+
51
+
52
+ def _install_macos() -> tuple[bool, str]:
53
+ label = "io.neruva.control"
54
+ plist_dir = Path.home() / "Library" / "LaunchAgents"
55
+ plist_dir.mkdir(parents=True, exist_ok=True)
56
+ plist = plist_dir / f"{label}.plist"
57
+ cmd_args = "".join(f" <string>{a}</string>\n" for a in _start_cmd())
58
+ plist.write_text(textwrap.dedent(f"""\
59
+ <?xml version="1.0" encoding="UTF-8"?>
60
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
61
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
62
+ <plist version="1.0">
63
+ <dict>
64
+ <key>Label</key><string>{label}</string>
65
+ <key>ProgramArguments</key>
66
+ <array>
67
+ {cmd_args} </array>
68
+ <key>RunAtLoad</key><true/>
69
+ <key>KeepAlive</key><true/>
70
+ <key>StandardOutPath</key>
71
+ <string>{config_dir()}/daemon.log</string>
72
+ <key>StandardErrorPath</key>
73
+ <string>{config_dir()}/daemon.err</string>
74
+ </dict>
75
+ </plist>
76
+ """), encoding="utf-8")
77
+ try:
78
+ # Unload first in case we're re-installing
79
+ subprocess.run(["launchctl", "unload", str(plist)], capture_output=True)
80
+ subprocess.run(["launchctl", "load", str(plist)], check=True, capture_output=True)
81
+ return True, f"launchd: loaded {plist}"
82
+ except Exception as e:
83
+ return False, f"launchctl load failed: {e}"
84
+
85
+
86
+ def _install_linux() -> tuple[bool, str]:
87
+ unit_dir = Path.home() / ".config" / "systemd" / "user"
88
+ unit_dir.mkdir(parents=True, exist_ok=True)
89
+ unit = unit_dir / "neruva-control.service"
90
+ exec_line = " ".join(_start_cmd())
91
+ unit.write_text(textwrap.dedent(f"""\
92
+ [Unit]
93
+ Description=Neruva Control daemon (Cockpit local controller)
94
+ After=network.target
95
+
96
+ [Service]
97
+ ExecStart={exec_line}
98
+ Restart=always
99
+ RestartSec=3
100
+ Environment=PYTHONUNBUFFERED=1
101
+
102
+ [Install]
103
+ WantedBy=default.target
104
+ """), encoding="utf-8")
105
+ if not shutil.which("systemctl"):
106
+ return False, "systemctl not found; skip auto-service. Run `neruva-control start` manually."
107
+ try:
108
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True, capture_output=True)
109
+ subprocess.run(["systemctl", "--user", "enable", "--now", "neruva-control.service"],
110
+ check=True, capture_output=True)
111
+ return True, f"systemd-user: enabled {unit}"
112
+ except Exception as e:
113
+ return False, f"systemctl --user enable failed: {e}"
114
+
115
+
116
+ def _install_windows() -> tuple[bool, str]:
117
+ """Use schtasks to register an ONLOGON task. Avoids needing admin."""
118
+ task_name = "NeruvaControl"
119
+ cmd_str = " ".join(f'"{a}"' for a in _start_cmd())
120
+ try:
121
+ # Delete any prior task silently
122
+ subprocess.run(["schtasks", "/Delete", "/TN", task_name, "/F"],
123
+ capture_output=True)
124
+ # Create on-logon task that runs in current session
125
+ subprocess.run([
126
+ "schtasks", "/Create", "/TN", task_name,
127
+ "/SC", "ONLOGON",
128
+ "/TR", cmd_str,
129
+ "/RL", "LIMITED",
130
+ "/F",
131
+ ], check=True, capture_output=True)
132
+ # Run it now so user doesn't need to log out
133
+ subprocess.run(["schtasks", "/Run", "/TN", task_name],
134
+ check=True, capture_output=True)
135
+ return True, f"Task Scheduler: registered task '{task_name}'"
136
+ except FileNotFoundError:
137
+ return False, "schtasks not on PATH; skipping auto-service"
138
+ except subprocess.CalledProcessError as e:
139
+ msg = (e.stderr or b"").decode(errors="replace").strip()
140
+ return False, f"schtasks /Create failed: {msg or e}"
141
+
142
+
143
+ def _install_service() -> tuple[bool, str]:
144
+ if sys.platform == "darwin":
145
+ return _install_macos()
146
+ if sys.platform == "win32":
147
+ return _install_windows()
148
+ if sys.platform.startswith("linux"):
149
+ return _install_linux()
150
+ return False, f"unsupported platform: {sys.platform}"
151
+
152
+
153
+ def _wait_for_daemon(timeout: float = 10.0) -> bool:
154
+ deadline = time.monotonic() + timeout
155
+ while time.monotonic() < deadline:
156
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
157
+ s.settimeout(0.4)
158
+ try:
159
+ s.connect(("127.0.0.1", PORT))
160
+ s.close()
161
+ return True
162
+ except OSError:
163
+ time.sleep(0.3)
164
+ return False
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Main
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def main(argv: list[str] | None = None) -> int:
172
+ p = argparse.ArgumentParser(
173
+ prog="neruva-control-install",
174
+ description=(
175
+ "One-shot install for the Neruva Control daemon. "
176
+ "Generates a token, registers a background service, and "
177
+ "prints a one-time URL to link your browser to Cockpit."
178
+ ),
179
+ )
180
+ p.add_argument("--no-service", action="store_true",
181
+ help="Skip OS service registration (run `neruva-control start` yourself).")
182
+ p.add_argument("--reinstall", action="store_true",
183
+ help="Rotate the token even if one already exists.")
184
+ p.add_argument("--base", default="https://app.neruva.io",
185
+ help="Cockpit base URL (default: https://app.neruva.io).")
186
+ args = p.parse_args(argv)
187
+
188
+ print()
189
+ print("Installing Neruva Control...")
190
+ print()
191
+
192
+ # 1. Token
193
+ if token_path().exists() and not args.reinstall:
194
+ from ._config import load_token
195
+ token = load_token()
196
+ print(f" [skip] Token already present at {token_path()}")
197
+ print(f" (re-run with --reinstall to rotate it)")
198
+ else:
199
+ token = gen_token()
200
+ write_token(token)
201
+ print(f" [ok] Token written to {token_path()}")
202
+
203
+ # 2. Service registration
204
+ if args.no_service:
205
+ print(" [skip] Service registration (--no-service set)")
206
+ service_msg = "skipped"
207
+ service_ok = False
208
+ else:
209
+ service_ok, service_msg = _install_service()
210
+ marker = "[ok]" if service_ok else "[warn]"
211
+ print(f" {marker} {service_msg}")
212
+
213
+ # 3. Wait for daemon to come up (if we just registered + started one)
214
+ if service_ok:
215
+ up = _wait_for_daemon()
216
+ if up:
217
+ print(f" [ok] Daemon listening on 127.0.0.1:{PORT}")
218
+ else:
219
+ print(f" [warn] Daemon didn't come up within 10s; check logs at {config_dir()}/daemon.log")
220
+ else:
221
+ print()
222
+ print(f" Auto-service didn't register. Start the daemon manually with:")
223
+ print(f" neruva-control start")
224
+ print(f" (then re-run this command, or run `neruva-control link`)")
225
+
226
+ # 4. Print the fragment URL
227
+ print()
228
+ print("=" * 70)
229
+ print()
230
+ print("Open this URL in your browser to link this machine:")
231
+ print()
232
+ print(f" {cockpit_link_url(token, args.base)}")
233
+ print()
234
+ print("Same machine, same browser. Token is stashed in localStorage; the")
235
+ print("URL fragment is stripped after first load. Bookmark app.neruva.io/cockpit.")
236
+ print()
237
+ print("=" * 70)
238
+ print()
239
+ return 0 if service_ok or args.no_service else 1
240
+
241
+
242
+ if __name__ == "__main__":
243
+ sys.exit(main())
@@ -0,0 +1,167 @@
1
+ """Projects -- the unit of organization in Cockpit.
2
+
3
+ A Project bundles:
4
+ - A name (also the records substrate namespace)
5
+ - A working directory on disk (the cwd for spawned sessions)
6
+ - A list of sessions (active + recent)
7
+ - A list of "pinned" files the user has attached to context
8
+
9
+ Persisted at ``~/.config/neruva/projects.json``. Single-user single-
10
+ machine for v1; multi-machine sync is post-v1 (decision 23238 #11).
11
+
12
+ Default project: created lazily on first session spawn if none exist.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import secrets
20
+ import threading
21
+ import time
22
+ from dataclasses import asdict, dataclass, field
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from ._config import config_dir
27
+
28
+
29
+ _LOCK = threading.Lock()
30
+
31
+
32
+ @dataclass
33
+ class Project:
34
+ id: str
35
+ name: str
36
+ path: str # absolute working directory
37
+ created_at_ms: int
38
+ last_active_ms: Optional[int] = None
39
+ pinned_files: list[str] = field(default_factory=list) # paths relative to project path
40
+
41
+ @property
42
+ def namespace(self) -> str:
43
+ """Records-substrate namespace. Slug of name; safe for URLs."""
44
+ s = re.sub(r"[^a-z0-9_-]+", "-", self.name.lower()).strip("-")
45
+ return s or self.id
46
+
47
+
48
+ def _projects_path() -> Path:
49
+ return config_dir() / "projects.json"
50
+
51
+
52
+ def load_all() -> list[Project]:
53
+ p = _projects_path()
54
+ if not p.exists():
55
+ return []
56
+ try:
57
+ data = json.loads(p.read_text(encoding="utf-8"))
58
+ return [Project(**d) for d in data.get("projects", [])]
59
+ except Exception:
60
+ return []
61
+
62
+
63
+ def save_all(projects: list[Project]) -> None:
64
+ p = _projects_path()
65
+ p.write_text(
66
+ json.dumps({"projects": [asdict(pr) for pr in projects]}, indent=2),
67
+ encoding="utf-8",
68
+ )
69
+ if os.name == "posix":
70
+ os.chmod(p, 0o600)
71
+
72
+
73
+ def get(project_id: str) -> Optional[Project]:
74
+ for pr in load_all():
75
+ if pr.id == project_id:
76
+ return pr
77
+ return None
78
+
79
+
80
+ def get_by_name(name: str) -> Optional[Project]:
81
+ for pr in load_all():
82
+ if pr.name == name:
83
+ return pr
84
+ return None
85
+
86
+
87
+ def create(name: str, path: Optional[str] = None) -> Project:
88
+ """Create a new project. Path defaults to ~/.neruva/<name>/ which is
89
+ auto-created. If user passes a real existing dir, we use it as-is."""
90
+ with _LOCK:
91
+ all_p = load_all()
92
+ if any(p.name == name for p in all_p):
93
+ raise ValueError(f"project '{name}' already exists")
94
+ if not path:
95
+ base = Path.home() / ".neruva" / "projects" / re.sub(r"[^a-zA-Z0-9_-]+", "-", name)
96
+ base.mkdir(parents=True, exist_ok=True)
97
+ path = str(base.resolve())
98
+ else:
99
+ p = Path(path).expanduser().resolve()
100
+ if not p.exists():
101
+ p.mkdir(parents=True, exist_ok=True)
102
+ path = str(p)
103
+ proj = Project(
104
+ id="p_" + secrets.token_hex(6),
105
+ name=name,
106
+ path=path,
107
+ created_at_ms=int(time.time() * 1000),
108
+ )
109
+ all_p.append(proj)
110
+ save_all(all_p)
111
+ return proj
112
+
113
+
114
+ def delete(project_id: str) -> bool:
115
+ """Remove project entry (does NOT delete files on disk)."""
116
+ with _LOCK:
117
+ all_p = load_all()
118
+ before = len(all_p)
119
+ all_p = [p for p in all_p if p.id != project_id]
120
+ if len(all_p) == before:
121
+ return False
122
+ save_all(all_p)
123
+ return True
124
+
125
+
126
+ def touch(project_id: str) -> None:
127
+ """Mark project as active (updates last_active_ms)."""
128
+ with _LOCK:
129
+ all_p = load_all()
130
+ for p in all_p:
131
+ if p.id == project_id:
132
+ p.last_active_ms = int(time.time() * 1000)
133
+ save_all(all_p)
134
+ return
135
+
136
+
137
+ def get_or_create_default() -> Project:
138
+ """Return an existing 'default' project or create one. Used when a
139
+ session is spawned without an explicit project."""
140
+ existing = get_by_name("default")
141
+ if existing:
142
+ return existing
143
+ return create("default")
144
+
145
+
146
+ def pin_file(project_id: str, rel_path: str) -> Optional[Project]:
147
+ with _LOCK:
148
+ all_p = load_all()
149
+ for p in all_p:
150
+ if p.id == project_id:
151
+ if rel_path not in p.pinned_files:
152
+ p.pinned_files.append(rel_path)
153
+ save_all(all_p)
154
+ return p
155
+ return None
156
+
157
+
158
+ def unpin_file(project_id: str, rel_path: str) -> Optional[Project]:
159
+ with _LOCK:
160
+ all_p = load_all()
161
+ for p in all_p:
162
+ if p.id == project_id:
163
+ if rel_path in p.pinned_files:
164
+ p.pinned_files.remove(rel_path)
165
+ save_all(all_p)
166
+ return p
167
+ return None