1313import socket
1414import struct
1515import sys
16+ import time
1617from pathlib import Path
1718
1819_HOME = Path (os .environ .get ("AGENT_MEM_HOME" ) or (Path .home () / ".agent-mem" ))
1920SOCKET_PATH = _HOME / "priming.sock"
21+ # Default plugin data dir ("ultan" plugin @ "ultan" marketplace) — where the
22+ # background installer leaves its lock/pid while provisioning.
23+ _PLUGIN_DATA = Path (
24+ os .environ .get ("CLAUDE_PLUGIN_DATA" )
25+ or (Path .home () / ".claude" / "plugins" / "data" / "ultan-ultan" )
26+ )
2027PATH_CHARS_RE = re .compile (r"^[a-zA-Z0-9_./-]+$" )
2128RECV_TIMEOUT_S = 5.0
2229
30+ _MSG_WARMING = (
31+ "# Ultan is starting up — not broken\n \n "
32+ "The daemon is warming (first start loads the retrieval models; allow 1-3\n "
33+ "minutes). Retry this search shortly. Live status: run `ultan doctor`.\n \n "
34+ "_Priming still works during warmup via the lexical fallback, so the\n "
35+ "session is not memory-blind meanwhile._\n "
36+ )
37+ _MSG_INSTALLING = (
38+ "# Ultan is still installing — not broken\n \n "
39+ "The plugin's background installer is provisioning the runtime (torch +\n "
40+ "models; minutes on a cold cache). Retry once it finishes. Progress:\n "
41+ "run `ultan doctor`.\n "
42+ )
43+ _MSG_DOWN = (
44+ "# Ultan daemon not running\n \n "
45+ f"No socket at `{ SOCKET_PATH } ` and no startup in progress. With the\n "
46+ "plugin installed the daemon lazy-starts on the next prompt — send any\n "
47+ "message and retry. Details: run `ultan doctor`. (From a source\n "
48+ "checkout: `uv run agent-mem-daemon -v`.)\n "
49+ )
50+
51+
52+ def _pid_alive (pid : object ) -> bool :
53+ try :
54+ os .kill (int (pid ), 0 ) # type: ignore[arg-type]
55+ except (TypeError , ValueError , ProcessLookupError ):
56+ return False
57+ except PermissionError :
58+ return True
59+ except OSError :
60+ return False
61+ return True
62+
63+
64+ def _startup_message () -> str | None :
65+ """A friendly explanation when the socket isn't answering, or None when
66+ the daemon is genuinely down (not installing, not warming). Checks the
67+ daemon's lifecycle flag first, then spawn/install breadcrumbs."""
68+ # 1. The daemon's own phase flag (written between pid-acquire and exit).
69+ try :
70+ state = json .loads ((_HOME / "daemon.state" ).read_text (encoding = "utf-8" ))
71+ if _pid_alive (state .get ("pid" )):
72+ return _MSG_WARMING # alive but socket not serving yet (or restarting)
73+ except (OSError , ValueError ):
74+ pass
75+ # 2. A spawn was attempted moments ago (pre-flag window, or older daemon).
76+ try :
77+ age = time .time () - (_HOME / ".daemon-spawn-attempt" ).stat ().st_mtime
78+ if age < 300 :
79+ return _MSG_WARMING
80+ except OSError :
81+ pass
82+ # 3. The plugin's background install is still running.
83+ if (_PLUGIN_DATA / ".install.pid" ).exists () or (_PLUGIN_DATA / ".install.lock" ).exists ():
84+ return _MSG_INSTALLING
85+ return None
86+
2387
2488def _looks_like_path (s : str ) -> bool :
2589 s = s .strip ()
@@ -119,12 +183,7 @@ def main() -> int:
119183 mode = "search"
120184
121185 if not SOCKET_PATH .exists ():
122- print (
123- "# Ultan daemon not running\n \n "
124- f"Socket missing at `{ SOCKET_PATH } `. Start the daemon:\n \n "
125- "```\n uv run agent-mem-daemon -v\n ```\n " ,
126- file = sys .stderr ,
127- )
186+ print (_startup_message () or _MSG_DOWN , file = sys .stderr )
128187 return 1
129188
130189 try :
@@ -134,6 +193,11 @@ def main() -> int:
134193 else :
135194 resp = _send_request ({"op" : "bm25_search" , "query" : arg , "k" : 8 })
136195 print (_render_search (resp , arg ))
196+ except (ConnectionError , OSError ):
197+ # Socket file present but not answering — stale socket or a daemon
198+ # mid-restart. Same friendly triage as the missing-socket path.
199+ print (_startup_message () or _MSG_DOWN , file = sys .stderr )
200+ return 1
137201 except Exception as e :
138202 print (f"# Ultan skill failed\n \n Error: { e !r} \n " , file = sys .stderr )
139203 return 1
0 commit comments